@hyperframes/studio 0.8.106 → 0.8.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +290 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +46 -23
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HF_AUDIO_FX_ATTR,
|
|
3
|
+
parseAudioFxChain,
|
|
4
|
+
serializeAudioFxChain,
|
|
5
|
+
type HfAudioFxChain,
|
|
6
|
+
} from "@hyperframes/core/audio-fx";
|
|
7
|
+
import {
|
|
8
|
+
clipsOverlap,
|
|
9
|
+
DEFAULT_CARVE,
|
|
10
|
+
HF_AUDIO_CARVE_ATTR,
|
|
11
|
+
normalizeCarveSettings,
|
|
12
|
+
type HfCarveSettings,
|
|
13
|
+
} from "@hyperframes/core/audio-carve";
|
|
14
|
+
import { resolveCarveSourceIds } from "@hyperframes/core/audio-groups";
|
|
15
|
+
import { parseAutomation, type HfAutomation } from "@hyperframes/core/audio-automation";
|
|
16
|
+
import {
|
|
17
|
+
carveBedRoles,
|
|
18
|
+
carverAgainst,
|
|
19
|
+
collectCarveCandidates,
|
|
20
|
+
CARVE_ABORTED,
|
|
21
|
+
isPromiseLike,
|
|
22
|
+
resolveNextCarveSettings,
|
|
23
|
+
} from "../../components/editor/useFxCarveGrouping";
|
|
24
|
+
import {
|
|
25
|
+
dropCarveOutput,
|
|
26
|
+
resolveCarveVoices,
|
|
27
|
+
withoutCarveLanes,
|
|
28
|
+
} from "../../components/editor/carveOutput";
|
|
29
|
+
import { carveLanes, measureCarve, mintCarveNodes } from "../../components/editor/useFxCarveNodes";
|
|
30
|
+
import { readClipClock } from "../../components/editor/clipAudioClock";
|
|
31
|
+
import {
|
|
32
|
+
automationAttrValue,
|
|
33
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
34
|
+
} from "../../components/editor/propertyPanelAutomation";
|
|
35
|
+
import { spanOf } from "../../components/editor/propertyPanelAudioFxGroupUtils";
|
|
36
|
+
|
|
37
|
+
export type WriteBedAttribute = (attr: string, value: string | null) => Promise<void>;
|
|
38
|
+
export type GroupClips = (clipIds: readonly string[], groupId: string) => Promise<void>;
|
|
39
|
+
|
|
40
|
+
export function readBedCarve(bed: Element): HfCarveSettings | null {
|
|
41
|
+
const raw = bed.getAttribute(HF_AUDIO_CARVE_ATTR);
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
try {
|
|
44
|
+
return normalizeCarveSettings(JSON.parse(raw));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readChain(bed: Element): HfAudioFxChain {
|
|
51
|
+
const raw = bed.getAttribute(HF_AUDIO_FX_ATTR);
|
|
52
|
+
try {
|
|
53
|
+
return raw ? parseAudioFxChain(raw) : { version: 1, nodes: [] };
|
|
54
|
+
} catch {
|
|
55
|
+
return { version: 1, nodes: [] };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readAutomation(bed: Element): HfAutomation {
|
|
60
|
+
const raw = bed.getAttribute(HF_AUDIO_AUTOMATION_ATTR);
|
|
61
|
+
try {
|
|
62
|
+
return raw ? parseAutomation(raw) : { version: 1, lanes: [] };
|
|
63
|
+
} catch {
|
|
64
|
+
return { version: 1, lanes: [] };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isDuckableBed(bed: Element | null): bed is Element {
|
|
69
|
+
if (bed === null || !carveBedRoles(bed.id, bed).couldBeBed) return false;
|
|
70
|
+
return carverAgainst(bed.ownerDocument, bed.id) === null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every overlapping clip with speech, including a video's own sound; never the bed itself. */
|
|
74
|
+
export function duckVoiceSources(doc: Document, bed: Element): string[] {
|
|
75
|
+
const bedSpan = spanOf(bed.getAttribute("data-start"), bed.getAttribute("data-duration"));
|
|
76
|
+
const others = Array.from(doc.querySelectorAll("audio[id], video[id]")).filter(
|
|
77
|
+
(el) => el.id !== bed.id,
|
|
78
|
+
);
|
|
79
|
+
const overlaps = (el: Element) =>
|
|
80
|
+
clipsOverlap(bedSpan, spanOf(el.getAttribute("data-start"), el.getAttribute("data-duration")));
|
|
81
|
+
return collectCarveCandidates(doc, others, overlaps, bed.id)
|
|
82
|
+
.filter((candidate) => candidate.kind === "voice" || candidate.kind === "unknown")
|
|
83
|
+
.map((candidate) => candidate.id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeMeasuredCarve(
|
|
87
|
+
doc: Document,
|
|
88
|
+
bed: Element,
|
|
89
|
+
settings: HfCarveSettings,
|
|
90
|
+
write: WriteBedAttribute,
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const expanded = resolveCarveSourceIds(doc, settings.sources).filter((id) => id !== bed.id);
|
|
93
|
+
const voices = resolveCarveVoices(doc, expanded);
|
|
94
|
+
if (voices.length === 0) return;
|
|
95
|
+
const measured = await measureCarve(doc, voices, settings.strength, {
|
|
96
|
+
src: bed.getAttribute("src"),
|
|
97
|
+
start: bed.getAttribute("data-start"),
|
|
98
|
+
clock: readClipClock((name) => bed.getAttribute(name)),
|
|
99
|
+
});
|
|
100
|
+
if (!measured) return;
|
|
101
|
+
const chain = readChain(bed);
|
|
102
|
+
const automation = readAutomation(bed);
|
|
103
|
+
const { next, carvedNodes, duckNode } = mintCarveNodes(chain, measured.carved, measured.duck);
|
|
104
|
+
await write(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
|
|
105
|
+
const lanes = carveLanes(carvedNodes, duckNode, measured.duck, measured.voiceMix, measured.bands);
|
|
106
|
+
const carriedOver = withoutCarveLanes(automation, chain);
|
|
107
|
+
const nextAutomation = { version: 1, lanes: [...carriedOver.lanes, ...lanes] };
|
|
108
|
+
await write(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(nextAutomation) || null);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type DuckOutcome = "ducked" | "no-voice" | "aborted" | "off";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Switch the carve on a music bed. On: carve against every overlapping voice at
|
|
115
|
+
* core's default strength (several voices are grouped first, as the carve lint
|
|
116
|
+
* requires). Off: `enabled:false`, which drops the generated filters and lanes.
|
|
117
|
+
*/
|
|
118
|
+
export async function setDuckUnderVoice(
|
|
119
|
+
doc: Document,
|
|
120
|
+
bed: Element,
|
|
121
|
+
on: boolean,
|
|
122
|
+
write: WriteBedAttribute,
|
|
123
|
+
groupClips?: GroupClips,
|
|
124
|
+
): Promise<DuckOutcome> {
|
|
125
|
+
const current = readBedCarve(bed);
|
|
126
|
+
if (!on) {
|
|
127
|
+
await dropCarveOutput(readChain(bed), readAutomation(bed), write);
|
|
128
|
+
await write(
|
|
129
|
+
HF_AUDIO_CARVE_ATTR,
|
|
130
|
+
JSON.stringify({ ...(current ?? DEFAULT_CARVE), enabled: false }),
|
|
131
|
+
);
|
|
132
|
+
return "off";
|
|
133
|
+
}
|
|
134
|
+
const sources = duckVoiceSources(doc, bed);
|
|
135
|
+
if (sources.length === 0) return "no-voice";
|
|
136
|
+
const resolved = resolveNextCarveSettings({ ...DEFAULT_CARVE, sources }, doc, groupClips);
|
|
137
|
+
const settings = isPromiseLike(resolved) ? await resolved : resolved;
|
|
138
|
+
if (settings === CARVE_ABORTED || settings === null) return "aborted";
|
|
139
|
+
await write(HF_AUDIO_CARVE_ATTR, JSON.stringify(settings));
|
|
140
|
+
await writeMeasuredCarve(doc, bed, settings, write);
|
|
141
|
+
return "ducked";
|
|
142
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
clipHasSound,
|
|
5
|
+
normalizeRequestBody,
|
|
6
|
+
normalizeToastText,
|
|
7
|
+
requestNormalizePlan,
|
|
8
|
+
} from "./clipMenuNormalize";
|
|
9
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
10
|
+
|
|
11
|
+
const clip = (over: Partial<TimelineElement>): TimelineElement => ({
|
|
12
|
+
id: "vo",
|
|
13
|
+
tag: "audio",
|
|
14
|
+
start: 0,
|
|
15
|
+
duration: 4,
|
|
16
|
+
track: 0,
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("clipHasSound", () => {
|
|
21
|
+
it("is true for audio and for a video with sound, false for silent or muted video", () => {
|
|
22
|
+
expect(clipHasSound(clip({}))).toBe(true);
|
|
23
|
+
expect(clipHasSound(clip({ tag: "video", hasAudio: true }))).toBe(true);
|
|
24
|
+
expect(clipHasSound(clip({ tag: "video", hasAudio: false }))).toBe(false);
|
|
25
|
+
expect(clipHasSound(clip({ tag: "video", hasAudio: true, muted: true }))).toBe(false);
|
|
26
|
+
expect(clipHasSound(clip({ tag: "img" }))).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("normalizeRequestBody", () => {
|
|
31
|
+
it("sends the played source window and the current gain", () => {
|
|
32
|
+
const body = normalizeRequestBody(
|
|
33
|
+
clip({ domId: "a-roll", playbackStart: 2, playbackRate: 2, volume: 0.5, duration: 3 }),
|
|
34
|
+
"talk.mp4",
|
|
35
|
+
);
|
|
36
|
+
expect(body).toEqual({
|
|
37
|
+
id: "a-roll",
|
|
38
|
+
src: "talk.mp4",
|
|
39
|
+
mediaStart: 2,
|
|
40
|
+
duration: 3,
|
|
41
|
+
playbackRate: 2,
|
|
42
|
+
volume: 0.5,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("normalizeToastText", () => {
|
|
48
|
+
it("names the target and the change", () => {
|
|
49
|
+
expect(
|
|
50
|
+
normalizeToastText({
|
|
51
|
+
targetLufs: -16,
|
|
52
|
+
projectedLufs: -16,
|
|
53
|
+
volume: 1.44,
|
|
54
|
+
changeDb: 3.2,
|
|
55
|
+
limitedBy: null,
|
|
56
|
+
}),
|
|
57
|
+
).toBe("Normalized to −16 LUFS (+3.2 dB)");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("says so when a limit held it back", () => {
|
|
61
|
+
expect(
|
|
62
|
+
normalizeToastText({
|
|
63
|
+
targetLufs: -16,
|
|
64
|
+
projectedLufs: -18.4,
|
|
65
|
+
volume: 4,
|
|
66
|
+
changeDb: 12,
|
|
67
|
+
limitedBy: "gain-ceiling",
|
|
68
|
+
}),
|
|
69
|
+
).toBe("Raised to −18 LUFS (+12.0 dB), the most the +12 dB ceiling allows");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("requestNormalizePlan", () => {
|
|
74
|
+
it("posts the project-relative source and returns the plan", async () => {
|
|
75
|
+
const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
|
|
76
|
+
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({ plan }));
|
|
77
|
+
const origin = window.location.origin;
|
|
78
|
+
const el = clip({ src: `${origin}/api/projects/p1/preview/assets/vo.wav` });
|
|
79
|
+
await expect(requestNormalizePlan("p1", el, fetchImpl)).resolves.toEqual(plan);
|
|
80
|
+
const [url, init] = fetchImpl.mock.calls[0] ?? [];
|
|
81
|
+
expect(url).toBe("/api/projects/p1/loudness/normalize");
|
|
82
|
+
expect(JSON.parse(String(init?.body))).toEqual(
|
|
83
|
+
expect.objectContaining({ src: "assets/vo.wav" }),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("surfaces the server's reason", async () => {
|
|
88
|
+
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
89
|
+
Response.json({ error: "#vo is silent" }, { status: 422 }),
|
|
90
|
+
);
|
|
91
|
+
await expect(
|
|
92
|
+
requestNormalizePlan("p1", clip({ src: "assets/vo.wav" }), fetchImpl),
|
|
93
|
+
).rejects.toThrow(/silent/);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
|
+
import { resolvePreviewRelative } from "../../utils/previewRelativePath";
|
|
3
|
+
import { resolveMediaPreviewUrl } from "./thumbnailUtils";
|
|
4
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
5
|
+
import { parseAutomation, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
|
|
6
|
+
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
|
7
|
+
|
|
8
|
+
export interface NormalizePlan {
|
|
9
|
+
targetLufs: number;
|
|
10
|
+
projectedLufs: number;
|
|
11
|
+
volume: number;
|
|
12
|
+
changeDb: number;
|
|
13
|
+
limitedBy: "gain-ceiling" | "true-peak" | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clipHasSound(el: TimelineElement): boolean {
|
|
17
|
+
if (el.tag === "audio") return true;
|
|
18
|
+
return el.tag === "video" && el.hasAudio === true && el.muted !== true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const VOLUME_LANE_REFUSAL =
|
|
22
|
+
"This clip's volume is automated. Remove its volume automation to normalize it.";
|
|
23
|
+
|
|
24
|
+
export function volumeLaneOwnsGain(el: TimelineElement): boolean {
|
|
25
|
+
if (!el.automation) return false;
|
|
26
|
+
try {
|
|
27
|
+
return parseAutomation(el.automation).lanes.some(
|
|
28
|
+
(lane) => lane.target === VOLUME_TARGET && lane.points.length > 0,
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class TimelineSaveError extends Error {}
|
|
36
|
+
|
|
37
|
+
export function throwUnlessSaved(outcome: TimelineEditOutcome | void): void {
|
|
38
|
+
if (outcome && outcome.status !== "saved") throw new TimelineSaveError(outcome.reason);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeRequestBody(el: TimelineElement, projectRelativeSrc: string) {
|
|
42
|
+
return {
|
|
43
|
+
id: el.domId ?? el.id,
|
|
44
|
+
src: projectRelativeSrc,
|
|
45
|
+
mediaStart: el.playbackStart ?? 0,
|
|
46
|
+
duration: el.duration > 0 ? el.duration : undefined,
|
|
47
|
+
playbackRate: el.playbackRate ?? 1,
|
|
48
|
+
volume: el.volume ?? 1,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const signedDb = (db: number) => `${db >= 0 ? "+" : "−"}${Math.abs(db).toFixed(1)} dB`;
|
|
53
|
+
const lufsText = (lufs: number) => `${lufs < 0 ? "−" : ""}${Math.abs(lufs).toFixed(0)} LUFS`;
|
|
54
|
+
|
|
55
|
+
const LIMIT_SUFFIX = {
|
|
56
|
+
"gain-ceiling": "the most the +12 dB ceiling allows",
|
|
57
|
+
"true-peak": "held back to keep peaks under −1.5 dBTP",
|
|
58
|
+
} satisfies Record<NonNullable<NormalizePlan["limitedBy"]>, string>;
|
|
59
|
+
|
|
60
|
+
export function normalizeToastText(plan: NormalizePlan): string {
|
|
61
|
+
if (!plan.limitedBy)
|
|
62
|
+
return `Normalized to ${lufsText(plan.targetLufs)} (${signedDb(plan.changeDb)})`;
|
|
63
|
+
const reached = `${lufsText(Math.round(plan.projectedLufs))}`;
|
|
64
|
+
return `Raised to ${reached} (${signedDb(plan.changeDb)}), ${LIMIT_SUFFIX[plan.limitedBy]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isNormalizePlan(value: unknown): value is NormalizePlan {
|
|
68
|
+
if (typeof value !== "object" || value === null) return false;
|
|
69
|
+
return ["targetLufs", "projectedLufs", "volume", "changeDb"].every(
|
|
70
|
+
(key) => typeof Reflect.get(value, key) === "number",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function requestNormalizePlan(
|
|
75
|
+
projectId: string,
|
|
76
|
+
el: TimelineElement,
|
|
77
|
+
fetchImpl: typeof fetch = fetch,
|
|
78
|
+
): Promise<NormalizePlan> {
|
|
79
|
+
const origin = window.location.origin;
|
|
80
|
+
const relative = resolvePreviewRelative(
|
|
81
|
+
resolveMediaPreviewUrl(el.src ?? "", projectId, origin),
|
|
82
|
+
projectId,
|
|
83
|
+
origin,
|
|
84
|
+
);
|
|
85
|
+
if (!relative) throw new Error("Only project media can be normalized");
|
|
86
|
+
const res = await fetchImpl(buildProjectApiPath(projectId, "/loudness/normalize"), {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "content-type": "application/json" },
|
|
89
|
+
body: JSON.stringify(normalizeRequestBody(el, relative)),
|
|
90
|
+
});
|
|
91
|
+
const body: unknown = await res.json().catch(() => null);
|
|
92
|
+
const plan = typeof body === "object" && body !== null ? Reflect.get(body, "plan") : null;
|
|
93
|
+
if (res.ok && isNormalizePlan(plan)) return plan;
|
|
94
|
+
const error = typeof body === "object" && body !== null ? Reflect.get(body, "error") : null;
|
|
95
|
+
throw new Error(typeof error === "string" ? error : "Could not measure this clip");
|
|
96
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
8
|
+
import { useCropPresetBarStore } from "../../components/editor/cropPresetStore";
|
|
9
|
+
import { ClipMenuToolItems, type ClipMenuToolGroup } from "./clipMenuToolItems";
|
|
10
|
+
|
|
11
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
12
|
+
configurable: true,
|
|
13
|
+
value: true,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
let root: Root | null = null;
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
act(() => root?.unmount());
|
|
20
|
+
root = null;
|
|
21
|
+
document.body.innerHTML = "";
|
|
22
|
+
useCropPresetBarStore.getState().close();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const video: TimelineElement = {
|
|
26
|
+
id: "talk",
|
|
27
|
+
tag: "video",
|
|
28
|
+
start: 0,
|
|
29
|
+
duration: 6,
|
|
30
|
+
track: 0,
|
|
31
|
+
hasAudio: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function renderItems(group: ClipMenuToolGroup, element: TimelineElement, currentTime = 2) {
|
|
35
|
+
const setQuiet = vi.fn(
|
|
36
|
+
async (_el: TimelineElement, _attr: string, _value: string | null, _label: string) => undefined,
|
|
37
|
+
);
|
|
38
|
+
const onClose = vi.fn();
|
|
39
|
+
const freeze = vi.fn((_el: TimelineElement, _time: number) => undefined);
|
|
40
|
+
const host = document.createElement("div");
|
|
41
|
+
document.body.append(host);
|
|
42
|
+
root = createRoot(host);
|
|
43
|
+
act(() => {
|
|
44
|
+
root?.render(
|
|
45
|
+
<TimelineEditProvider value={{ onSetElementAttributeQuiet: setQuiet, onFreezeFrame: freeze }}>
|
|
46
|
+
<ClipMenuToolItems
|
|
47
|
+
group={group}
|
|
48
|
+
element={element}
|
|
49
|
+
currentTime={currentTime}
|
|
50
|
+
onClose={onClose}
|
|
51
|
+
/>
|
|
52
|
+
</TimelineEditProvider>,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
return { setQuiet, onClose, freeze };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function openSubmenu(label: string) {
|
|
59
|
+
const row = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).find(
|
|
60
|
+
(button) => button.textContent?.startsWith(label),
|
|
61
|
+
);
|
|
62
|
+
act(() => row?.click());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pick(text: string) {
|
|
66
|
+
const item = Array.from(
|
|
67
|
+
document.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
|
|
68
|
+
).find((button) => button.textContent?.endsWith(text));
|
|
69
|
+
act(() => item?.click());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("ClipMenuToolItems", () => {
|
|
73
|
+
it("Voice writes a single-choice data-fx-chain on a video with sound", () => {
|
|
74
|
+
const { setQuiet, onClose } = renderItems("sound", video);
|
|
75
|
+
openSubmenu("Voice");
|
|
76
|
+
pick("Clean");
|
|
77
|
+
const [element, attr, value] = setQuiet.mock.calls[0] ?? [];
|
|
78
|
+
expect(element).toBe(video);
|
|
79
|
+
expect(attr).toBe("data-fx-chain");
|
|
80
|
+
expect(String(value)).toContain('"fromPreset":"voice-clean"');
|
|
81
|
+
expect(onClose).toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("Voice None drops the attribute", () => {
|
|
85
|
+
const { setQuiet } = renderItems("sound", video);
|
|
86
|
+
openSubmenu("Voice");
|
|
87
|
+
pick("None");
|
|
88
|
+
expect(setQuiet.mock.calls[0]?.slice(1, 3)).toEqual(["data-fx-chain", null]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("has no Voice on a video without sound", () => {
|
|
92
|
+
renderItems("sound", { ...video, hasAudio: false });
|
|
93
|
+
expect(document.body.textContent).toBe("");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("Look writes the preset form and None removes it", () => {
|
|
97
|
+
const { setQuiet } = renderItems("picture", video);
|
|
98
|
+
openSubmenu("Look");
|
|
99
|
+
pick("Warm daylight");
|
|
100
|
+
openSubmenu("Look");
|
|
101
|
+
pick("None");
|
|
102
|
+
expect(setQuiet.mock.calls.map((call) => call.slice(1, 3))).toEqual([
|
|
103
|
+
["data-color-grading", '{"preset":"warm-daylight","intensity":1}'],
|
|
104
|
+
["data-color-grading", null],
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("Crop opens the preset bar for this clip", () => {
|
|
109
|
+
renderItems("picture", video);
|
|
110
|
+
const crop = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(
|
|
111
|
+
(button) => button.textContent === "Crop",
|
|
112
|
+
);
|
|
113
|
+
act(() => crop?.click());
|
|
114
|
+
expect(useCropPresetBarStore.getState().openFor).toEqual({ hfId: undefined, id: "talk" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("offers no picture tools on an audio clip", () => {
|
|
118
|
+
renderItems("picture", { ...video, tag: "audio" });
|
|
119
|
+
expect(document.body.textContent).toBe("");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("Freeze frame calls the freeze mutation at the playhead on a video", () => {
|
|
123
|
+
const { freeze } = renderItems("time", video, 3.2);
|
|
124
|
+
const item = document.querySelector<HTMLButtonElement>('[role="menuitem"]');
|
|
125
|
+
expect(item?.textContent).toBe("Freeze frame");
|
|
126
|
+
act(() => item?.click());
|
|
127
|
+
expect(freeze).toHaveBeenCalledWith(video, 3.2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("Freeze frame is disabled outside the clip and absent on images", () => {
|
|
131
|
+
renderItems("time", video, 9);
|
|
132
|
+
expect(document.querySelector<HTMLButtonElement>('[role="menuitem"]')?.disabled).toBe(true);
|
|
133
|
+
act(() => root?.unmount());
|
|
134
|
+
root = null;
|
|
135
|
+
document.body.innerHTML = "";
|
|
136
|
+
renderItems("time", { ...video, tag: "img" });
|
|
137
|
+
expect(document.body.textContent).toBe("");
|
|
138
|
+
});
|
|
139
|
+
});
|