@hyperframes/studio 0.8.107 → 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-h4mktSdm.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-D8GKc2D9.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-C842tcVa.js} +165 -165
- package/dist/assets/{index-zoqPmBF6.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-4OZC426H.js → chunk-AOWVEVEU.js} +3 -503
- 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 +11 -2
- package/dist/index.html +2 -2
- package/dist/index.js +12416 -11195
- 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 +1 -0
- package/src/components/EditorShell.tsx +2 -0
- 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/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/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/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +59 -0
- package/src/components/editor/mediaAudioEdits.ts +40 -11
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +7 -3
- package/src/components/editor/useDomEditNudge.ts +17 -12
- 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/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- 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/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- 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/index.ts +1 -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/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/useClipToolState.ts +92 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -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/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -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
|
+
});
|