@hyperframes/studio 0.8.109 → 0.8.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-HTFVHUJ4.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CMSgrT2F.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-CYP6dMd7.js → index-Co6W8IVk.js} +205 -204
- package/dist/index.d.ts +21 -6
- package/dist/index.html +2 -2
- package/dist/index.js +5351 -4755
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaLinkEdits.test.ts +19 -6
- package/src/components/editor/mediaLinkEdits.ts +34 -13
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +9 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
- package/src/hooks/timelineLinkEditPlan.ts +6 -1
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useRenderClipContent.ts +3 -5
- package/src/hooks/useTimelineEditing.ts +13 -0
- package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
- package/src/hooks/useTimelineLinkEditing.ts +5 -0
- package/src/index.ts +4 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
- package/src/player/components/ClipContextMenu.tsx +1 -1
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +41 -0
- package/src/player/components/TimelineClip.tsx +3 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +60 -0
- package/src/player/components/audioClipLink.ts +77 -0
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
- package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
- package/src/player/components/clipMenuAudioItems.tsx +16 -37
- package/src/player/components/clipMenuLinkItems.test.ts +84 -0
- package/src/player/components/clipMenuLinkItems.tsx +54 -9
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +79 -6
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipGestureHandlers.ts +26 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +20 -2
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipDrag.ts +12 -5
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +0 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
|
@@ -9,6 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
|
9
9
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
10
10
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
11
11
|
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
12
|
+
import { openAudioGainDialog } from "../player/components/audioGainDialogStore";
|
|
12
13
|
import type { LinkShortcutCallbacks } from "./linkShortcuts";
|
|
13
14
|
|
|
14
15
|
// Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap,
|
|
@@ -190,6 +191,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
// Only with a sound clip selected, so bare G still reaches the canvas grid toggle otherwise.
|
|
195
|
+
if (key === STUDIO_PLAIN_KEYS.audioGain && !event.shiftKey && !event.altKey) {
|
|
196
|
+
if (!cb.readOnlyPreview && openAudioGainDialog()) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
193
202
|
if (key === "b" && !event.shiftKey && !event.altKey) {
|
|
194
203
|
event.preventDefault();
|
|
195
204
|
const { activeTool, setActiveTool } = usePlayerStore.getState();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
|
7
|
+
import type { TimelineElement } from "../player";
|
|
8
|
+
|
|
9
|
+
const saved = vi.hoisted(() => ({ inputs: [] as Array<Record<string, unknown>> }));
|
|
10
|
+
vi.mock("../utils/studioFileHistory", () => ({
|
|
11
|
+
saveProjectFilesWithHistory: vi.fn(async (input: Record<string, unknown>) => {
|
|
12
|
+
saved.inputs.push(input);
|
|
13
|
+
return [];
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { useSetElementsAttribute } from "./timelineElementsAttribute";
|
|
18
|
+
|
|
19
|
+
const clip = (id: string, sourceFile?: string): TimelineElement => ({
|
|
20
|
+
id,
|
|
21
|
+
domId: id,
|
|
22
|
+
tag: "audio",
|
|
23
|
+
start: 0,
|
|
24
|
+
duration: 1,
|
|
25
|
+
track: 0,
|
|
26
|
+
sourceFile,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("useSetElementsAttribute", () => {
|
|
30
|
+
it("writes every clip's attribute in one history entry, one builder per file", async () => {
|
|
31
|
+
const showToast = vi.fn();
|
|
32
|
+
let write: ReturnType<typeof useSetElementsAttribute> | null = null;
|
|
33
|
+
function Probe() {
|
|
34
|
+
write = useSetElementsAttribute({
|
|
35
|
+
projectIdRef: { current: "p1" },
|
|
36
|
+
activeCompPath: "index.html",
|
|
37
|
+
showToast,
|
|
38
|
+
writeProjectFile: vi.fn(async () => {}),
|
|
39
|
+
recordEdit: vi.fn(async () => {}),
|
|
40
|
+
previewIframeRef: { current: null },
|
|
41
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
42
|
+
});
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const root = createRoot(document.createElement("div"));
|
|
46
|
+
act(() => root.render(createElement(Probe)));
|
|
47
|
+
await write?.(
|
|
48
|
+
[
|
|
49
|
+
{ element: clip("a"), value: "0.5" },
|
|
50
|
+
{ element: clip("b"), value: "2" },
|
|
51
|
+
{ element: clip("c", "scene.html"), value: "1.5" },
|
|
52
|
+
],
|
|
53
|
+
"data-volume",
|
|
54
|
+
"Audio Gain",
|
|
55
|
+
);
|
|
56
|
+
act(() => root.unmount());
|
|
57
|
+
expect(showToast).not.toHaveBeenCalled();
|
|
58
|
+
expect(saved.inputs).toHaveLength(1);
|
|
59
|
+
const input = saved.inputs[0];
|
|
60
|
+
expect(input?.["label"]).toBe("Audio Gain");
|
|
61
|
+
const files = input?.["files"];
|
|
62
|
+
if (typeof files !== "object" || files === null) throw new Error("no files");
|
|
63
|
+
expect(Object.keys(files).sort()).toEqual(["index.html", "scene.html"]);
|
|
64
|
+
const build = Reflect.get(files, "index.html");
|
|
65
|
+
const html = '<audio id="a" data-volume="1"></audio><audio id="b"></audio>';
|
|
66
|
+
const out = typeof build === "function" ? String(build(html)) : "";
|
|
67
|
+
expect(out).toContain('id="a" data-volume="0.5"');
|
|
68
|
+
expect(out).toMatch(/id="b"[^>]*data-volume="2"/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("refuses while recording, like the single-clip write", async () => {
|
|
72
|
+
saved.inputs.length = 0;
|
|
73
|
+
const showToast = vi.fn();
|
|
74
|
+
let write: ReturnType<typeof useSetElementsAttribute> | null = null;
|
|
75
|
+
function Probe() {
|
|
76
|
+
write = useSetElementsAttribute({
|
|
77
|
+
projectIdRef: { current: "p1" },
|
|
78
|
+
activeCompPath: "index.html",
|
|
79
|
+
showToast,
|
|
80
|
+
writeProjectFile: vi.fn(async () => {}),
|
|
81
|
+
recordEdit: vi.fn(async () => {}),
|
|
82
|
+
previewIframeRef: { current: null },
|
|
83
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
84
|
+
isRecordingRef: { current: true },
|
|
85
|
+
});
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const root = createRoot(document.createElement("div"));
|
|
89
|
+
act(() => root.render(createElement(Probe)));
|
|
90
|
+
await write?.([{ element: clip("a"), value: "0.5" }], "data-volume", "Audio Gain");
|
|
91
|
+
act(() => root.unmount());
|
|
92
|
+
expect(saved.inputs).toHaveLength(0);
|
|
93
|
+
expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
|
|
3
|
+
import { syncStoredElementAttribute } from "../player/lib/automationStoreSync";
|
|
4
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
5
|
+
import { applyPatchByTarget } from "../utils/sourcePatcher";
|
|
6
|
+
import {
|
|
7
|
+
buildPatchTarget,
|
|
8
|
+
findTimelineElementInIframe,
|
|
9
|
+
readFileContent,
|
|
10
|
+
} from "./timelineEditingHelpers";
|
|
11
|
+
import type { UseTimelineElementVisibilityEditingInput } from "./timelineTrackVisibility";
|
|
12
|
+
import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
|
|
13
|
+
|
|
14
|
+
type ElementAttributeEdits = Parameters<
|
|
15
|
+
NonNullable<TimelineEditCallbacks["onSetElementsAttributeQuiet"]>
|
|
16
|
+
>[0];
|
|
17
|
+
|
|
18
|
+
type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
|
|
19
|
+
|
|
20
|
+
function editsByFile(
|
|
21
|
+
edits: ElementAttributeEdits,
|
|
22
|
+
activeCompPath: string | null,
|
|
23
|
+
): Map<string, Array<{ target: PatchTarget; value: string | null }>> {
|
|
24
|
+
const byFile = new Map<string, Array<{ target: PatchTarget; value: string | null }>>();
|
|
25
|
+
for (const { element, value } of edits) {
|
|
26
|
+
const target = buildPatchTarget(element);
|
|
27
|
+
if (!target) throw new Error("A clip has no id to save it by");
|
|
28
|
+
const path = element.sourceFile || activeCompPath || "index.html";
|
|
29
|
+
byFile.set(path, [...(byFile.get(path) ?? []), { target, value }]);
|
|
30
|
+
}
|
|
31
|
+
return byFile;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function patchLive(
|
|
35
|
+
iframe: HTMLIFrameElement | null,
|
|
36
|
+
edits: ElementAttributeEdits,
|
|
37
|
+
attr: string,
|
|
38
|
+
activeCompPath: string | null,
|
|
39
|
+
): void {
|
|
40
|
+
for (const { element, value } of edits) {
|
|
41
|
+
const node = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
42
|
+
if (value === null) node?.removeAttribute(attr);
|
|
43
|
+
else node?.setAttribute(attr, value);
|
|
44
|
+
syncStoredElementAttribute(element, attr, value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One attribute written on several clips as a single save and a single undo step. */
|
|
49
|
+
export function useSetElementsAttribute({
|
|
50
|
+
projectIdRef,
|
|
51
|
+
activeCompPath,
|
|
52
|
+
showToast,
|
|
53
|
+
writeProjectFile,
|
|
54
|
+
recordEdit,
|
|
55
|
+
previewIframeRef,
|
|
56
|
+
pendingTimelineEditPathRef,
|
|
57
|
+
isRecordingRef,
|
|
58
|
+
}: UseTimelineElementVisibilityEditingInput) {
|
|
59
|
+
return useCallback(
|
|
60
|
+
async (
|
|
61
|
+
edits: ElementAttributeEdits,
|
|
62
|
+
attr: string,
|
|
63
|
+
label: string,
|
|
64
|
+
): Promise<TimelineEditOutcome> => {
|
|
65
|
+
const projectId = projectForTimelineSave(
|
|
66
|
+
isRecordingRef?.current,
|
|
67
|
+
projectIdRef.current,
|
|
68
|
+
showToast,
|
|
69
|
+
);
|
|
70
|
+
if (typeof projectId !== "string") return projectId;
|
|
71
|
+
if (edits.length === 0) return { status: "saved" };
|
|
72
|
+
try {
|
|
73
|
+
const byFile = editsByFile(edits, activeCompPath);
|
|
74
|
+
const files = Object.fromEntries(
|
|
75
|
+
[...byFile].map(([path, patches]) => [
|
|
76
|
+
path,
|
|
77
|
+
(before: string) =>
|
|
78
|
+
patches.reduce(
|
|
79
|
+
(html, { target, value }) =>
|
|
80
|
+
applyPatchByTarget(html, target, { type: "attribute", property: attr, value }),
|
|
81
|
+
before,
|
|
82
|
+
),
|
|
83
|
+
]),
|
|
84
|
+
);
|
|
85
|
+
for (const path of byFile.keys()) pendingTimelineEditPathRef.current.add(path);
|
|
86
|
+
await saveProjectFilesWithHistory({
|
|
87
|
+
projectId,
|
|
88
|
+
label,
|
|
89
|
+
files,
|
|
90
|
+
readFile: (path) => readFileContent(projectId, path),
|
|
91
|
+
writeFile: writeProjectFile,
|
|
92
|
+
recordEdit,
|
|
93
|
+
});
|
|
94
|
+
patchLive(previewIframeRef.current, edits, attr, activeCompPath);
|
|
95
|
+
return { status: "saved" };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const reason = error instanceof Error ? error.message : "Could not save the clips";
|
|
98
|
+
showToast(reason, "error");
|
|
99
|
+
return { status: "failed", reason };
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[
|
|
103
|
+
projectIdRef,
|
|
104
|
+
activeCompPath,
|
|
105
|
+
showToast,
|
|
106
|
+
writeProjectFile,
|
|
107
|
+
recordEdit,
|
|
108
|
+
previewIframeRef,
|
|
109
|
+
pendingTimelineEditPathRef,
|
|
110
|
+
isRecordingRef,
|
|
111
|
+
],
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
|
+
import { clipSyncState } from "../player/components/clipSync";
|
|
4
5
|
import { clipsToUnlink, planLinkEdit } from "./timelineLinkEditPlan";
|
|
5
6
|
|
|
6
7
|
const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
@@ -111,3 +112,30 @@ describe("planLinkEdit into sync", () => {
|
|
|
111
112
|
expect(html).toBe('<audio id="talk-audio" data-playback-start="0.5"></audio>');
|
|
112
113
|
});
|
|
113
114
|
});
|
|
115
|
+
|
|
116
|
+
describe("link edit plan", () => {
|
|
117
|
+
const PAIR =
|
|
118
|
+
'<div><video id="talk" src="talk.mp4" muted data-start="2" data-duration="6"></video><audio id="a" src="%SRC%" data-start="3" data-duration="3" data-media-start="1"></audio></div>';
|
|
119
|
+
const linkPlan = (src: string, audioEl: TimelineElement) => {
|
|
120
|
+
const v = clip("talk", "video", { muted: true });
|
|
121
|
+
const plan = planLinkEdit({ kind: "link", elements: [v, audioEl] }, [v, audioEl]);
|
|
122
|
+
return plan?.transform(PAIR.replace("%SRC%", src)) ?? "";
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
it("links a trimmed same-file audio with a sync origin that shows a nonzero offset", () => {
|
|
126
|
+
const trimmed = clip("a", "audio", { start: 3, duration: 3, playbackStart: 0 });
|
|
127
|
+
const out = linkPlan("talk.mp4", trimmed);
|
|
128
|
+
expect(out).toMatch(/<video[^>]*data-link="lk-1"[^>]*data-sync-origin="lk-1"/);
|
|
129
|
+
expect(out).toMatch(/<audio[^>]*data-link="lk-1"[^>]*data-sync-origin="lk-1"/);
|
|
130
|
+
const v = clip("talk", "video", { muted: true, syncOrigin: "lk-1" });
|
|
131
|
+
const a = { ...trimmed, syncOrigin: "lk-1" };
|
|
132
|
+
expect(clipSyncState(a, [v, a], 30)?.frames).toBe(30);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("links different files without a sync origin", () => {
|
|
136
|
+
const music = clip("a", "audio", { src: "bgm.mp3" });
|
|
137
|
+
const out = linkPlan("bgm.mp3", music);
|
|
138
|
+
expect(out).toContain('data-link="lk-1"');
|
|
139
|
+
expect(out).not.toContain("data-sync-origin");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
mergeAudioInSource,
|
|
14
14
|
pickDetachedAudioTrack,
|
|
15
15
|
setLinkInSource,
|
|
16
|
+
sharesSourceFile,
|
|
16
17
|
} from "../components/editor/mediaLinkEdits";
|
|
17
18
|
|
|
18
19
|
export interface LinkEditPlan {
|
|
@@ -92,7 +93,11 @@ export function planLinkEdit(
|
|
|
92
93
|
const targets = targetsOf(edit.elements);
|
|
93
94
|
const anchor = edit.elements[0];
|
|
94
95
|
if (!targets || !anchor) return null;
|
|
95
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
label: "Link clips",
|
|
98
|
+
anchor,
|
|
99
|
+
transform: (s) => linkInSource(s, targets, { syncOrigin: sharesSourceFile(edit.elements) }),
|
|
100
|
+
};
|
|
96
101
|
}
|
|
97
102
|
case "detach": {
|
|
98
103
|
const target = buildPatchTarget(edit.element);
|
|
@@ -6,6 +6,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
|
|
|
6
6
|
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
|
|
7
7
|
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
8
8
|
import type { TimelineElement } from "../player/store/timelineElement";
|
|
9
|
+
import { useAudioGainDialogStore } from "../player/components/audioGainDialogStore";
|
|
9
10
|
|
|
10
11
|
/** Minimal valid fixture — TimelineElement only requires these five fields. */
|
|
11
12
|
const bgmElement: TimelineElement = {
|
|
@@ -474,3 +475,36 @@ describe("hotkeys with the preview read-only", () => {
|
|
|
474
475
|
expect(cb.handleDomEditElementDelete).toHaveBeenCalledTimes(1);
|
|
475
476
|
});
|
|
476
477
|
});
|
|
478
|
+
|
|
479
|
+
describe("dispatchPlainKey — G opens Audio Gain", () => {
|
|
480
|
+
const video: TimelineElement = {
|
|
481
|
+
id: "b-roll",
|
|
482
|
+
key: "b-roll",
|
|
483
|
+
tag: "video",
|
|
484
|
+
start: 0,
|
|
485
|
+
duration: 4,
|
|
486
|
+
track: 1,
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
afterEach(() => useAudioGainDialogStore.getState().close());
|
|
490
|
+
|
|
491
|
+
it("opens the dialog for the selected clips with sound and owns the key", () => {
|
|
492
|
+
usePlayerStore.setState({
|
|
493
|
+
elements: [bgmElement, video],
|
|
494
|
+
selectedElementId: "bgm",
|
|
495
|
+
selectedElementIds: new Set(["bgm", "b-roll"]),
|
|
496
|
+
});
|
|
497
|
+
const event = press("g");
|
|
498
|
+
dispatchPlainKey(event, "g", callbacks());
|
|
499
|
+
expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["bgm"]);
|
|
500
|
+
expect(event.defaultPrevented).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("leaves G to the grid toggle when nothing selected has sound", () => {
|
|
504
|
+
usePlayerStore.setState({ elements: [video], selectedElementId: "b-roll" });
|
|
505
|
+
const event = press("g");
|
|
506
|
+
dispatchPlainKey(event, "g", callbacks());
|
|
507
|
+
expect(useAudioGainDialogStore.getState().targetKeys).toBeNull();
|
|
508
|
+
expect(event.defaultPrevented).toBe(false);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
@@ -443,7 +443,14 @@ describe("useDomGeometryCommits resize of a cropped element", () => {
|
|
|
443
443
|
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
444
444
|
const press = (type: string, clientX: number) =>
|
|
445
445
|
act(() =>
|
|
446
|
-
handle.dispatchEvent(
|
|
446
|
+
handle.dispatchEvent(
|
|
447
|
+
new PointerEvent(type, {
|
|
448
|
+
bubbles: true,
|
|
449
|
+
pointerId: 3,
|
|
450
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
451
|
+
clientX,
|
|
452
|
+
}),
|
|
453
|
+
),
|
|
447
454
|
);
|
|
448
455
|
press("pointerdown", 100);
|
|
449
456
|
press("pointermove", 100 - by);
|
|
@@ -14,6 +14,7 @@ import { AudioWaveform, rendersWaveform } from "../player/components/AudioWavefo
|
|
|
14
14
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
15
15
|
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
|
|
16
16
|
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
|
|
17
|
+
import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap";
|
|
17
18
|
import { clipHasSound } from "../player/components/clipMenuNormalize";
|
|
18
19
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
19
20
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
@@ -91,11 +92,8 @@ function renderAudioClip(
|
|
|
91
92
|
return createElement(
|
|
92
93
|
ClipPeakMarks,
|
|
93
94
|
{
|
|
94
|
-
peaksUrl:
|
|
95
|
-
sourceWindow:
|
|
96
|
-
mediaStart: el.playbackStart ?? 0,
|
|
97
|
-
sourceSpan: el.duration * (el.playbackRate ?? 1),
|
|
98
|
-
},
|
|
95
|
+
peaksUrl: clipPeaksUrl(el.src, pid),
|
|
96
|
+
sourceWindow: clipSourceWindow(el),
|
|
99
97
|
gain: el.volume ?? 1,
|
|
100
98
|
},
|
|
101
99
|
waveform,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
27
27
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
28
28
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
29
|
+
import { useSetElementsAttribute } from "./timelineElementsAttribute";
|
|
29
30
|
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
30
31
|
import { useTimelineEditGuard } from "./useTimelineEditGuard";
|
|
31
32
|
import {
|
|
@@ -409,6 +410,17 @@ export function useTimelineEditing({
|
|
|
409
410
|
isRecordingRef,
|
|
410
411
|
});
|
|
411
412
|
|
|
413
|
+
const setElementsAttribute = useSetElementsAttribute({
|
|
414
|
+
projectIdRef,
|
|
415
|
+
activeCompPath,
|
|
416
|
+
showToast,
|
|
417
|
+
writeProjectFile,
|
|
418
|
+
recordEdit,
|
|
419
|
+
previewIframeRef,
|
|
420
|
+
pendingTimelineEditPathRef,
|
|
421
|
+
isRecordingRef,
|
|
422
|
+
});
|
|
423
|
+
|
|
412
424
|
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
|
413
425
|
projectIdRef,
|
|
414
426
|
activeCompPath,
|
|
@@ -532,6 +544,7 @@ export function useTimelineEditing({
|
|
|
532
544
|
},
|
|
533
545
|
setElementFxAttribute: {
|
|
534
546
|
...setElementFxAttribute,
|
|
547
|
+
setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
|
|
535
548
|
setQuiet: track(
|
|
536
549
|
guard(
|
|
537
550
|
(element) => [element],
|
|
@@ -109,4 +109,16 @@ describe("useTimelineLinkEditing", () => {
|
|
|
109
109
|
expect(recordEdit.mock.calls[0]?.[0].label).toBe("Unlink clips");
|
|
110
110
|
unmount();
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
it("unlink leaves nothing selected, so a later trim moves only the grabbed clip", async () => {
|
|
114
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
115
|
+
new Response(JSON.stringify({ content: SOURCE }), { status: 200 }),
|
|
116
|
+
);
|
|
117
|
+
const { api, unmount } = await renderLinkEditing();
|
|
118
|
+
usePlayerStore.getState().setSelection(["talk", "talk-audio"], "talk");
|
|
119
|
+
await act(async () => api.handleLinkEdit({ kind: "unlink", elements: [video, audio] }));
|
|
120
|
+
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
|
|
121
|
+
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
|
122
|
+
unmount();
|
|
123
|
+
});
|
|
112
124
|
});
|
|
@@ -49,6 +49,10 @@ export function withLinkPartners(selection: readonly TimelineElement[]): Timelin
|
|
|
49
49
|
return [...selection, ...elements.filter((el) => keys.has(keyOf(el)) && !known.has(keyOf(el)))];
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function clearSelectionOnUnlink(edit: TimelineLinkEdit): void {
|
|
53
|
+
if (edit.kind === "unlink") usePlayerStore.getState().clearSelection();
|
|
54
|
+
}
|
|
55
|
+
|
|
52
56
|
export function useTimelineLinkEditing({
|
|
53
57
|
projectIdRef,
|
|
54
58
|
activeCompPath,
|
|
@@ -71,6 +75,7 @@ export function useTimelineLinkEditing({
|
|
|
71
75
|
const pid = projectIdRef.current;
|
|
72
76
|
const plan = planLinkEdit(edit, usePlayerStore.getState().elements);
|
|
73
77
|
if (!pid || !plan) return;
|
|
78
|
+
clearSelectionOnUnlink(edit);
|
|
74
79
|
const path = plan.anchor.sourceFile || activeCompPath || "index.html";
|
|
75
80
|
pendingTimelineEditPathRef.current.add(path);
|
|
76
81
|
const queued = editQueueRef.current.then(() =>
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,10 @@ export {
|
|
|
32
32
|
formatTime,
|
|
33
33
|
} from "./player";
|
|
34
34
|
export { usePreviewIframeStore } from "./player/store/previewIframeStore";
|
|
35
|
+
export {
|
|
36
|
+
openAudioGainDialog,
|
|
37
|
+
useAudioGainDialogStore,
|
|
38
|
+
} from "./player/components/audioGainDialogStore";
|
|
35
39
|
export type {
|
|
36
40
|
PlayerHandle,
|
|
37
41
|
PlayerHandleElement,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { audioGainToDb } from "@hyperframes/core/audio-gain";
|
|
5
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
6
|
+
import { AudioGainDialog } from "./AudioGainDialog";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
9
|
+
|
|
10
|
+
const onNotice = vi.fn();
|
|
11
|
+
const setQuiet = vi.fn(async () => {});
|
|
12
|
+
const setMany = vi.fn(
|
|
13
|
+
async (_edits: ReadonlyArray<{ value: string | null }>, _attr: string, _label: string) => {},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
17
|
+
useStudioShellContextOptional: () => null,
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("../../contexts/TimelineEditContext", () => ({
|
|
20
|
+
useTimelineEditContextOptional: () => ({
|
|
21
|
+
onSetElementAttributeQuiet: setQuiet,
|
|
22
|
+
onSetElementsAttributeQuiet: setMany,
|
|
23
|
+
onNotice,
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const harness = createHappyDomRootHarness();
|
|
28
|
+
const clip = (id: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
29
|
+
id,
|
|
30
|
+
tag: "audio",
|
|
31
|
+
start: 0,
|
|
32
|
+
duration: 2,
|
|
33
|
+
track: 0,
|
|
34
|
+
src: `${id}.wav`,
|
|
35
|
+
...extra,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function stubPeaks(peaks: Record<string, number[]>) {
|
|
39
|
+
vi.stubGlobal(
|
|
40
|
+
"fetch",
|
|
41
|
+
vi.fn(async (url: string) => {
|
|
42
|
+
const name = Object.keys(peaks).find((key) => String(url).includes(`/peaks/${key}.wav`));
|
|
43
|
+
return name
|
|
44
|
+
? Response.json({ binSeconds: 1, bins: peaks[name] })
|
|
45
|
+
: new Response("", { status: 404 });
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function open(elements: TimelineElement[]) {
|
|
51
|
+
const host = document.createElement("div");
|
|
52
|
+
document.body.appendChild(host);
|
|
53
|
+
act(() => harness.mount(host).render(<AudioGainDialog elements={elements} onClose={() => {}} />));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const row = (text: string) =>
|
|
57
|
+
[...document.querySelectorAll("label")].find((label) => label.textContent?.startsWith(text));
|
|
58
|
+
const choose = (text: string) =>
|
|
59
|
+
act(() => row(text)?.querySelector<HTMLInputElement>("input[type=radio]")?.click());
|
|
60
|
+
function typeDb(text: string, value: string) {
|
|
61
|
+
const input = row(text)?.querySelector<HTMLInputElement>("input[type=number]");
|
|
62
|
+
if (!input) throw new Error(`no field for ${text}`);
|
|
63
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
64
|
+
act(() => {
|
|
65
|
+
setter?.call(input, value);
|
|
66
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const ok = () => [...document.querySelectorAll("button")].find((b) => b.textContent === "OK");
|
|
70
|
+
const peakText = () => document.querySelector("[data-testid='audio-gain-peak']")?.textContent;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
usePlayerStore.getState().beginTimelineSession("p1");
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
});
|
|
76
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
77
|
+
|
|
78
|
+
describe("AudioGainDialog", () => {
|
|
79
|
+
it("shows the selection's peak amplitude at the clips' current gain", async () => {
|
|
80
|
+
stubPeaks({ pk1: [0.25, 0.5] });
|
|
81
|
+
open([clip("pk1", { volume: 2 })]);
|
|
82
|
+
await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: 0.0 dB"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("Set Gain writes every selected clip in one undo step", async () => {
|
|
86
|
+
stubPeaks({});
|
|
87
|
+
const a = clip("set-a");
|
|
88
|
+
const b = clip("set-b", { volume: 0.5 });
|
|
89
|
+
open([a, b]);
|
|
90
|
+
choose("Set Gain to");
|
|
91
|
+
typeDb("Set Gain to", "-6");
|
|
92
|
+
await act(async () => ok()?.click());
|
|
93
|
+
expect(setMany).toHaveBeenCalledTimes(1);
|
|
94
|
+
const [edits, attr, label] = setMany.mock.calls[0] ?? [];
|
|
95
|
+
expect(attr).toBe("data-volume");
|
|
96
|
+
expect(label).toBe("Audio Gain");
|
|
97
|
+
expect(edits?.map((e) => audioGainToDb(Number(e.value)))).toEqual([
|
|
98
|
+
expect.closeTo(-6, 3),
|
|
99
|
+
expect.closeTo(-6, 3),
|
|
100
|
+
]);
|
|
101
|
+
expect(setQuiet).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("Normalize All Peaks brings each clip's own peak to the target", async () => {
|
|
105
|
+
stubPeaks({ na1: [0.5], na2: [0.25] });
|
|
106
|
+
open([clip("na1"), clip("na2")]);
|
|
107
|
+
await vi.waitFor(() => expect(peakText()).not.toContain("measuring"));
|
|
108
|
+
choose("Normalize All Peaks to");
|
|
109
|
+
typeDb("Normalize All Peaks to", "-3");
|
|
110
|
+
await act(async () => ok()?.click());
|
|
111
|
+
const [edits] = setMany.mock.calls[0] ?? [];
|
|
112
|
+
const gains = edits?.map((e) => Number(e.value)) ?? [];
|
|
113
|
+
expect(audioGainToDb(0.5 * (gains[0] ?? 0))).toBeCloseTo(-3, 3);
|
|
114
|
+
expect(audioGainToDb(0.25 * (gains[1] ?? 0))).toBeCloseTo(-3, 3);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("refuses a peak target above 0 dB", async () => {
|
|
118
|
+
stubPeaks({ over: [0.5] });
|
|
119
|
+
open([clip("over")]);
|
|
120
|
+
await vi.waitFor(() => expect(peakText()).not.toContain("measuring"));
|
|
121
|
+
choose("Normalize Max Peak to");
|
|
122
|
+
typeDb("Normalize Max Peak to", "2");
|
|
123
|
+
expect(ok()?.disabled).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("disables the peak options when the clip cannot be measured", async () => {
|
|
127
|
+
stubPeaks({});
|
|
128
|
+
open([clip("unmeasured")]);
|
|
129
|
+
await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: —"));
|
|
130
|
+
expect(row("Normalize Max Peak to")?.querySelector("input")?.disabled).toBe(true);
|
|
131
|
+
expect(row("Adjust Gain by")?.querySelector("input")?.disabled).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|