@hyperframes/studio 0.8.107 → 0.8.109

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 (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/chunk-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -1,33 +1,80 @@
1
- import type { TimelineElement } from "../store/timelineElement";
1
+ import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
2
2
 
3
- function mediaFileKey(src: string | undefined): string | null {
3
+ type LinkScoped = Pick<TimelineElement, "sourceFile" | "compositionScope">;
4
+ type LinkedElement = Pick<TimelineElement, "id" | "key" | "link"> & LinkScoped;
5
+
6
+ const keyOf = (element: Pick<TimelineElement, "id" | "key">) => element.key ?? element.id;
7
+
8
+ const SOURCE_BASE = "https://project.invalid/";
9
+ const SOURCE_ORIGIN = new URL(SOURCE_BASE).origin;
10
+
11
+ export function mediaAssetIdentity(
12
+ element: Pick<TimelineElement, "src" | "sourceFile">,
13
+ ): string | null {
14
+ const src = element.src?.trim();
4
15
  if (!src) return null;
5
- const path = src.split(/[?#]/, 1)[0] ?? "";
6
- const segment = path.split(/[/\\]/).pop()?.trim().toLowerCase() ?? "";
7
- return segment.length > 0 ? segment : null;
16
+ try {
17
+ const url = new URL(src, new URL(element.sourceFile ?? "index.html", SOURCE_BASE));
18
+ if (url.origin !== SOURCE_ORIGIN) return `${url.origin}${url.pathname}${url.search}`;
19
+ return decodeURIComponent(`${url.origin}${url.pathname}`);
20
+ } catch {
21
+ return null;
22
+ }
8
23
  }
9
24
 
10
- export function audioPillFlags(
11
- audio: Pick<TimelineElement, "id" | "tag" | "src" | "hidden" | "audioGroupHidden">,
12
- elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
13
- ): { muted: boolean; linked: boolean } {
14
- return {
15
- muted: audio.hidden === true || audio.audioGroupHidden === true,
16
- linked: isLinkedVideoAudio(audio, elements),
17
- };
25
+ function isLinked(element: Pick<TimelineElement, "link">): boolean {
26
+ return typeof element.link === "string" && element.link.length > 0;
18
27
  }
19
28
 
20
- /** An audio pill whose file is also a video clip on this timeline. */
21
- export function isLinkedVideoAudio(
22
- audio: Pick<TimelineElement, "id" | "tag" | "src">,
23
- elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
29
+ export function sharesLinkGroup(
30
+ a: Pick<TimelineElement, "link"> & LinkScoped,
31
+ b: Pick<TimelineElement, "link"> & LinkScoped,
24
32
  ): boolean {
25
- if (audio.tag.trim().toLowerCase() !== "audio") return false;
26
- const key = mediaFileKey(audio.src);
27
- if (!key) return false;
28
- return elements.some((element) => {
29
- if (element.id === audio.id) return false;
30
- if (element.tag.trim().toLowerCase() !== "video") return false;
31
- return mediaFileKey(element.src) === key;
32
- });
33
+ return isLinked(a) && a.link === b.link && sameCompositionScope(a, b);
34
+ }
35
+
36
+ export function audioPillFlags(
37
+ audio: Pick<TimelineElement, "hidden" | "audioGroupHidden">,
38
+ _elements?: readonly unknown[],
39
+ ): { muted: boolean } {
40
+ return { muted: audio.hidden === true || audio.audioGroupHidden === true };
41
+ }
42
+
43
+ export function linkedMembersOf<T extends LinkedElement>(
44
+ element: T,
45
+ elements: readonly T[],
46
+ linked = true,
47
+ ): T[] {
48
+ if (!linked || !isLinked(element)) return [element];
49
+ const members = elements.filter((candidate) => sharesLinkGroup(candidate, element));
50
+ return members.some((member) => keyOf(member) === keyOf(element))
51
+ ? members
52
+ : [element, ...members];
53
+ }
54
+
55
+ export function expandToLinkedMembers(
56
+ keys: Iterable<string>,
57
+ elements: readonly LinkedElement[],
58
+ linked = true,
59
+ ): Set<string> {
60
+ const expanded = new Set(keys);
61
+ if (!linked) return expanded;
62
+ const seeds = elements.filter((el) => expanded.has(keyOf(el)) && isLinked(el));
63
+ for (const element of elements) {
64
+ if (seeds.some((seed) => sharesLinkGroup(seed, element))) expanded.add(keyOf(element));
65
+ }
66
+ return expanded;
67
+ }
68
+
69
+ export function linkedGestureKeys(
70
+ selected: ReadonlySet<string>,
71
+ grabbed: LinkedElement,
72
+ elements: readonly LinkedElement[],
73
+ altKey: boolean,
74
+ linked = true,
75
+ ): Set<string> {
76
+ const grabbedKey = keyOf(grabbed);
77
+ if (altKey) return new Set([grabbedKey]);
78
+ const base = selected.has(grabbedKey) ? selected : [grabbedKey];
79
+ return expandToLinkedMembers(base, elements, linked);
33
80
  }
@@ -0,0 +1,78 @@
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 { usePlayerStore } from "../store/playerStore";
7
+ import { usePreviewIframeStore } from "../store/previewIframeStore";
8
+ import type { TimelineElement } from "../store/timelineElement";
9
+
10
+ const onNotice = vi.fn();
11
+ const setQuiet = vi.fn(async () => {});
12
+
13
+ vi.mock("../../contexts/StudioContext", () => ({
14
+ useStudioShellContextOptional: () => null,
15
+ }));
16
+ vi.mock("../../contexts/TimelineEditContext", () => ({
17
+ useTimelineEditContextOptional: () => ({ onSetElementAttributeQuiet: setQuiet, onNotice }),
18
+ }));
19
+
20
+ const harness = createHappyDomRootHarness();
21
+ const tour: TimelineElement = {
22
+ id: "tour",
23
+ tag: "video",
24
+ start: 0,
25
+ duration: 4,
26
+ track: 0,
27
+ hasAudio: true,
28
+ src: "tour.mp4",
29
+ };
30
+
31
+ function mountPreview(): void {
32
+ const iframe = document.createElement("iframe");
33
+ document.body.appendChild(iframe);
34
+ const doc = iframe.contentDocument;
35
+ if (!doc) throw new Error("iframe has no document");
36
+ doc.body.innerHTML =
37
+ '<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>';
38
+ usePreviewIframeStore.getState().setIframe(iframe);
39
+ }
40
+
41
+ function render(part: "normalize" | "duck") {
42
+ const host = document.createElement("div");
43
+ document.body.appendChild(host);
44
+ act(() =>
45
+ harness
46
+ .mount(host)
47
+ .render(<ClipMenuAudioItems part={part} element={tour} onClose={() => {}} />),
48
+ );
49
+ return host;
50
+ }
51
+
52
+ afterEach(() => {
53
+ usePreviewIframeStore.getState().setIframe(null);
54
+ vi.unstubAllGlobals();
55
+ });
56
+
57
+ describe("ClipMenuAudioItems in a host without Studio's shell", () => {
58
+ it("offers Normalize and Duck from the timeline session and the live preview", () => {
59
+ usePlayerStore.getState().beginTimelineSession("p1");
60
+ mountPreview();
61
+ expect(render("normalize").textContent).toContain("Normalize loudness");
62
+ expect(render("duck").textContent).toContain("Duck under voice");
63
+ });
64
+
65
+ it("normalizes through the session's project and reports through onNotice", async () => {
66
+ usePlayerStore.getState().beginTimelineSession("p1");
67
+ mountPreview();
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 button = render("normalize").querySelector("button");
72
+ await act(async () => button?.click());
73
+ await vi.waitFor(() => expect(onNotice).toHaveBeenCalled());
74
+ expect(String(fetchSpy.mock.calls[0]?.[0])).toContain("/api/projects/p1/loudness/normalize");
75
+ expect(setQuiet).toHaveBeenCalledWith(tour, "data-volume", "2", "Normalize loudness");
76
+ expect(onNotice).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info");
77
+ });
78
+ });
@@ -0,0 +1,168 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { createHappyDomRootHarness } from "./testRootHarness";
5
+ import { ClipMenuAudioItems } from "./clipMenuAudioItems";
6
+ import type { TimelineElement } from "../store/timelineElement";
7
+
8
+ const showToast = vi.fn();
9
+ const setQuiet = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => {});
10
+ let previewDoc: Document | null = null;
11
+ const iframe = {
12
+ get contentDocument() {
13
+ return previewDoc;
14
+ },
15
+ };
16
+
17
+ vi.mock("../../contexts/StudioContext", () => ({
18
+ useStudioShellContextOptional: () => ({
19
+ projectId: "p1",
20
+ showToast,
21
+ previewIframeRef: { current: iframe },
22
+ }),
23
+ }));
24
+ vi.mock("../../contexts/TimelineEditContext", () => ({
25
+ useTimelineEditContextOptional: () => ({ onSetElementAttributeQuiet: setQuiet }),
26
+ }));
27
+
28
+ const harness = createHappyDomRootHarness();
29
+
30
+ function render(element: TimelineElement, part: "normalize" | "duck" = "normalize") {
31
+ const host = document.createElement("div");
32
+ document.body.appendChild(host);
33
+ act(() =>
34
+ harness
35
+ .mount(host)
36
+ .render(<ClipMenuAudioItems part={part} element={element} onClose={() => {}} />),
37
+ );
38
+ return host;
39
+ }
40
+
41
+ const base = { start: 0, duration: 4, track: 0 };
42
+ const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
43
+ const clickItem = async (host: HTMLElement, label: string) => {
44
+ const button = [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(label));
45
+ await act(async () => button?.click());
46
+ };
47
+
48
+ beforeEach(() => {
49
+ showToast.mockClear();
50
+ setQuiet.mockReset();
51
+ setQuiet.mockResolvedValue(undefined);
52
+ });
53
+
54
+ describe("ClipMenuAudioItems", () => {
55
+ it("offers nothing on a muted video", () => {
56
+ const host = render({ ...base, id: "b", tag: "video", hasAudio: true, muted: true });
57
+ expect(host.textContent).toBe("");
58
+ });
59
+
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("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
+ );
104
+ const element: TimelineElement = {
105
+ ...base,
106
+ id: "a-roll",
107
+ tag: "video",
108
+ hasAudio: true,
109
+ src: "talk.mp4",
110
+ };
111
+ const host = render(element);
112
+ const button = [...host.querySelectorAll("button")].find((b) =>
113
+ b.textContent?.includes("Normalize loudness"),
114
+ );
115
+ 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();
149
+ });
150
+
151
+ it("stops ducking at the first refused save and says why", async () => {
152
+ previewDoc = document.implementation.createHTMLDocument("c");
153
+ previewDoc.body.innerHTML = `<audio id="music" src="music.mp3" data-start="0" data-duration="10"></audio>
154
+ <audio id="voiceover" src="vo.wav" data-start="1" data-duration="3"></audio>`;
155
+ setQuiet.mockResolvedValue({
156
+ status: "refused",
157
+ reason: "Cannot edit timeline while recording",
158
+ });
159
+ const host = render({ ...base, id: "music", tag: "audio", src: "music.mp3" }, "duck");
160
+ await clickItem(host, "Duck under voice");
161
+ await vi.waitFor(() =>
162
+ expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error"),
163
+ );
164
+ expect(setQuiet).toHaveBeenCalledTimes(1);
165
+ expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Ducks under"), "info");
166
+ previewDoc = null;
167
+ });
168
+ });
@@ -0,0 +1,116 @@
1
+ import { formatAudioGain } from "@hyperframes/core/audio-gain";
2
+ import type { TimelineElement } from "../store/timelineElement";
3
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
4
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
5
+ import { usePlayerStore } from "../store/playerStore";
6
+ import { useLivePreviewIframe } from "../store/previewIframeStore";
7
+ import {
8
+ clipHasSound,
9
+ normalizeToastText,
10
+ requestNormalizePlan,
11
+ throwUnlessSaved,
12
+ TimelineSaveError,
13
+ VOLUME_LANE_REFUSAL,
14
+ volumeLaneOwnsGain,
15
+ } from "./clipMenuNormalize";
16
+ import { offersDuck, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
17
+
18
+ const ITEM_CLASS =
19
+ "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer";
20
+
21
+ const DUCK_TOAST: Record<DuckOutcome, string> = {
22
+ ducked: "Ducks under the voice. Normalize the voice first for the most even result.",
23
+ off: "No longer ducks under the voice.",
24
+ "no-voice": "No overlapping voice to duck under.",
25
+ aborted: "Could not group the voices to duck under.",
26
+ };
27
+
28
+ /** Normalize loudness (one-shot) or Duck under voice (toggle), placed separately in the sound group. */
29
+ export function ClipMenuAudioItems({
30
+ part,
31
+ element,
32
+ onClose,
33
+ }: {
34
+ part: "normalize" | "duck";
35
+ element: TimelineElement;
36
+ onClose: () => void;
37
+ }) {
38
+ const shell = useStudioShellContextOptional();
39
+ const { onSetElementAttributeQuiet, onGroupClips, onNotice } = useTimelineEditContextOptional();
40
+ const liveIframe = useLivePreviewIframe();
41
+ const sessionProjectId = usePlayerStore((s) => s.timelineProjectId);
42
+ const projectId = shell?.projectId ?? sessionProjectId;
43
+ const showToast = shell?.showToast ?? onNotice;
44
+ const doc = (shell?.previewIframeRef.current ?? liveIframe)?.contentDocument ?? null;
45
+ const bed = doc?.getElementById(element.domId ?? element.id) ?? null;
46
+ const ducked = bed ? readBedCarve(bed)?.enabled === true : false;
47
+ if (!clipHasSound(element) || !projectId || !showToast || !onSetElementAttributeQuiet) {
48
+ return null;
49
+ }
50
+
51
+ const normalize = async () => {
52
+ onClose();
53
+ if (volumeLaneOwnsGain(element)) {
54
+ showToast(VOLUME_LANE_REFUSAL, "error");
55
+ return;
56
+ }
57
+ try {
58
+ const plan = await requestNormalizePlan(projectId, element);
59
+ throwUnlessSaved(
60
+ await onSetElementAttributeQuiet(
61
+ element,
62
+ "data-volume",
63
+ formatAudioGain(plan.volume),
64
+ "Normalize loudness",
65
+ ),
66
+ );
67
+ showToast(normalizeToastText(plan), "info");
68
+ } catch (error) {
69
+ showToast(error instanceof Error ? error.message : String(error), "error");
70
+ }
71
+ };
72
+
73
+ const toggleDuck = async () => {
74
+ onClose();
75
+ if (!doc || !bed) return;
76
+ const outcome = await setDuckUnderVoice(
77
+ doc,
78
+ bed,
79
+ !ducked,
80
+ async (attr, value) => {
81
+ throwUnlessSaved(
82
+ await onSetElementAttributeQuiet(element, attr, value, "Duck under voice"),
83
+ );
84
+ },
85
+ onGroupClips ? (ids, groupId) => onGroupClips(ids, groupId, "Voice") : undefined,
86
+ ).catch((error: unknown): DuckOutcome | TimelineSaveError =>
87
+ error instanceof TimelineSaveError ? error : "aborted",
88
+ );
89
+ if (outcome instanceof TimelineSaveError) {
90
+ showToast(outcome.message, "error");
91
+ return;
92
+ }
93
+ showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info");
94
+ };
95
+
96
+ if (part === "normalize") {
97
+ return (
98
+ <button type="button" role="menuitem" className={ITEM_CLASS} onClick={() => void normalize()}>
99
+ <span>Normalize loudness</span>
100
+ </button>
101
+ );
102
+ }
103
+ if (!offersDuck(doc, bed)) return null;
104
+ return (
105
+ <button
106
+ type="button"
107
+ role="menuitemcheckbox"
108
+ aria-checked={ducked}
109
+ className={ITEM_CLASS}
110
+ onClick={() => void toggleDuck()}
111
+ >
112
+ <span>Duck under voice</span>
113
+ <span aria-hidden="true">{ducked ? "✓" : ""}</span>
114
+ </button>
115
+ );
116
+ }
@@ -0,0 +1,117 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { duckVoiceSources, isDuckableBed, readBedCarve, setDuckUnderVoice } from "./clipMenuDuck";
4
+
5
+ function compose(body: string): Document {
6
+ const doc = document.implementation.createHTMLDocument("c");
7
+ doc.body.innerHTML = body;
8
+ return doc;
9
+ }
10
+
11
+ const BED = `<audio id="music" src="music.mp3" data-start="0" data-duration="10"></audio>`;
12
+
13
+ describe("duckVoiceSources", () => {
14
+ it("offers an overlapping video with sound and a voiceover, never the bed or silent video", () => {
15
+ const doc = compose(`${BED}
16
+ <video id="talk" src="talk.mp4" data-start="0" data-duration="6" data-has-audio="true"></video>
17
+ <video id="b-roll" src="b.mp4" data-start="0" data-duration="6" muted></video>
18
+ <audio id="voiceover" src="vo.wav" data-start="2" data-duration="3"></audio>
19
+ <audio id="late-vo" src="vo2.wav" data-start="20" data-duration="3"></audio>`);
20
+ const bed = doc.getElementById("music");
21
+ expect(bed && duckVoiceSources(doc, bed)).toEqual(["talk", "voiceover"]);
22
+ });
23
+ });
24
+
25
+ describe("isDuckableBed", () => {
26
+ it("allows a music clip and refuses a voice clip", () => {
27
+ const doc = compose(`${BED}<audio id="voiceover" src="voiceover.wav"></audio>`);
28
+ expect(isDuckableBed(doc.getElementById("music"))).toBe(true);
29
+ expect(isDuckableBed(doc.getElementById("voiceover"))).toBe(false);
30
+ });
31
+
32
+ it("refuses a clip another bed is already carving against", () => {
33
+ const doc = compose(
34
+ `<audio id="music" src="music.mp3" data-fx-carve='{"enabled":true,"sources":["sting"],"strength":0.25}'></audio><audio id="sting" src="sting-bed.mp3"></audio>`,
35
+ );
36
+ expect(isDuckableBed(doc.getElementById("sting"))).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe("setDuckUnderVoice", () => {
41
+ it("writes a carve at core's default strength naming the one voice", async () => {
42
+ const doc = compose(
43
+ `${BED}<audio id="voiceover" src="vo.wav" data-start="1" data-duration="3"></audio>`,
44
+ );
45
+ const bed = doc.getElementById("music");
46
+ if (!bed) throw new Error("fixture");
47
+ const write = vi.fn(async (attr: string, value: string | null) => {
48
+ if (value === null) bed.removeAttribute(attr);
49
+ else bed.setAttribute(attr, value);
50
+ });
51
+ await expect(setDuckUnderVoice(doc, bed, true, write)).resolves.toBe("ducked");
52
+ expect(readBedCarve(bed)).toEqual({ enabled: true, sources: ["voiceover"], strength: 0.25 });
53
+ });
54
+
55
+ it("groups several voices first so the carve names one group", async () => {
56
+ const doc = compose(`${BED}
57
+ <audio id="voiceover" src="vo.wav" data-start="1" data-duration="3"></audio>
58
+ <audio id="narration" src="n.wav" data-start="5" data-duration="3"></audio>`);
59
+ const bed = doc.getElementById("music");
60
+ if (!bed) throw new Error("fixture");
61
+ const write = vi.fn(async (attr: string, value: string | null) => {
62
+ if (value !== null) bed.setAttribute(attr, value);
63
+ });
64
+ const group = vi.fn(async () => {});
65
+ await setDuckUnderVoice(doc, bed, true, write, group);
66
+ expect(group).toHaveBeenCalledWith(["voiceover", "narration"], "voiceover-2");
67
+ expect(readBedCarve(bed)?.sources).toEqual(["voiceover-2"]);
68
+ });
69
+
70
+ it("reports no voice instead of writing an empty carve", async () => {
71
+ const doc = compose(BED);
72
+ const bed = doc.getElementById("music");
73
+ if (!bed) throw new Error("fixture");
74
+ const write = vi.fn(async () => {});
75
+ await expect(setDuckUnderVoice(doc, bed, true, write)).resolves.toBe("no-voice");
76
+ expect(write).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("turning it off disables the carve and drops the generated nodes and lanes", async () => {
80
+ const chain = {
81
+ version: 1,
82
+ nodes: [
83
+ { id: "c1", type: "peaking", enabled: true, fromCarve: true, params: {} },
84
+ { id: "eq", type: "highpass", enabled: true, params: {} },
85
+ ],
86
+ };
87
+ const automation = {
88
+ version: 1,
89
+ lanes: [
90
+ {
91
+ target: "fx.c1.gain",
92
+ points: [
93
+ { t: 0, v: 0 },
94
+ { t: 1, v: -3 },
95
+ ],
96
+ },
97
+ ],
98
+ };
99
+ const doc = compose(BED);
100
+ const bed = doc.getElementById("music");
101
+ if (!bed) throw new Error("fixture");
102
+ bed.setAttribute(
103
+ "data-fx-carve",
104
+ JSON.stringify({ enabled: true, sources: ["vo"], strength: 0.25 }),
105
+ );
106
+ bed.setAttribute("data-fx-chain", JSON.stringify(chain));
107
+ bed.setAttribute("data-automation", JSON.stringify(automation));
108
+ const writes: Array<[string, string | null]> = [];
109
+ await setDuckUnderVoice(doc, bed, false, async (attr, value) => {
110
+ writes.push([attr, value]);
111
+ });
112
+ const byAttr = new Map(writes);
113
+ expect(JSON.parse(byAttr.get("data-fx-carve") ?? "{}").enabled).toBe(false);
114
+ expect(byAttr.get("data-automation")).toBeNull();
115
+ expect(byAttr.get("data-fx-chain")).not.toContain("c1");
116
+ });
117
+ });