@hyperframes/studio 0.8.108 → 0.8.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
- package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +44 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7021 -5669
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +3 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +36 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +6 -0
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
- package/src/player/components/clipMenuAudioItems.tsx +31 -23
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuToolItems.tsx +1 -3
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +11 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -1,34 +1,231 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
});
|
|
21
|
-
expect(audioPillFlags({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
3
|
+
import {
|
|
4
|
+
audioPillFlags,
|
|
5
|
+
expandToLinkedMembers,
|
|
6
|
+
linkedGestureKeys,
|
|
7
|
+
linkedMembersOf,
|
|
8
|
+
mediaAssetIdentity,
|
|
9
|
+
} from "./audioClipLink";
|
|
10
|
+
import { createTimelineElementFromManifestClip, parseTimelineFromDOM } from "../lib/timelineDOM";
|
|
11
|
+
|
|
12
|
+
const video = { id: "talk", link: "lk-1" };
|
|
13
|
+
const audio = { id: "talk-audio", link: "lk-1" };
|
|
14
|
+
const other = { id: "music", link: "lk-2" };
|
|
15
|
+
const otherAudio = { id: "music-audio", link: "lk-2" };
|
|
16
|
+
const plain = { id: "title" };
|
|
17
|
+
const elements = [video, audio, other, otherAudio, plain];
|
|
18
|
+
|
|
19
|
+
describe("audioPillFlags", () => {
|
|
20
|
+
it("greys a hidden clip and a muted group", () => {
|
|
21
|
+
expect(audioPillFlags({ hidden: true }).muted).toBe(true);
|
|
22
|
+
expect(audioPillFlags({ audioGroupHidden: true }).muted).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("mediaAssetIdentity", () => {
|
|
27
|
+
it("tells same-named files in different folders apart", () => {
|
|
28
|
+
expect(mediaAssetIdentity({ src: "assets/one/talk.mp4" })).not.toBe(
|
|
29
|
+
mediaAssetIdentity({ src: "assets/two/talk.mp4" }),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("resolves a src against its own source file and drops query and hash", () => {
|
|
34
|
+
expect(mediaAssetIdentity({ src: "../assets/talk.mp4?v=2", sourceFile: "scenes/a.html" })).toBe(
|
|
35
|
+
mediaAssetIdentity({ src: "./assets/talk.mp4#t=1" }),
|
|
36
|
+
);
|
|
37
|
+
expect(mediaAssetIdentity({ src: "assets/City%20Ride.mp4" })).toBe(
|
|
38
|
+
mediaAssetIdentity({ src: "assets/City Ride.mp4" }),
|
|
39
|
+
);
|
|
40
|
+
expect(mediaAssetIdentity({ src: "" })).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps a remote url's query, since it can select a different file", () => {
|
|
44
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1" })).not.toBe(
|
|
45
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=2" }),
|
|
46
|
+
);
|
|
47
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a%26x=b" })).not.toBe(
|
|
48
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a&x=b" }),
|
|
49
|
+
);
|
|
50
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4%3Fclip=a" })).not.toBe(
|
|
51
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?clip=a" }),
|
|
52
|
+
);
|
|
53
|
+
expect(mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1#t=3" })).toBe(
|
|
54
|
+
mediaAssetIdentity({ src: "https://cdn.example.com/v.mp4?id=1" }),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("expandToLinkedMembers", () => {
|
|
60
|
+
it("adds every partner of a linked clip", () => {
|
|
61
|
+
expect(expandToLinkedMembers(["talk"], elements)).toEqual(new Set(["talk", "talk-audio"]));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leaves unlinked clips alone and does not pull in other groups", () => {
|
|
65
|
+
expect(expandToLinkedMembers(["title"], elements)).toEqual(new Set(["title"]));
|
|
66
|
+
expect(expandToLinkedMembers(["talk-audio", "title"], elements)).toEqual(
|
|
67
|
+
new Set(["talk-audio", "talk", "title"]),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prefers the store key over the id", () => {
|
|
72
|
+
const keyed = [
|
|
73
|
+
{ id: "a", key: "k-a", link: "lk-9" },
|
|
74
|
+
{ id: "b", key: "k-b", link: "lk-9" },
|
|
75
|
+
];
|
|
76
|
+
expect(expandToLinkedMembers(["k-a"], keyed)).toEqual(new Set(["k-a", "k-b"]));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("link groups stay inside their composition", () => {
|
|
81
|
+
const root = [
|
|
82
|
+
{ id: "v", link: "lk-1", sourceFile: undefined },
|
|
83
|
+
{ id: "a", link: "lk-1", sourceFile: undefined },
|
|
84
|
+
];
|
|
85
|
+
const child = [
|
|
86
|
+
{ id: "cv", key: "child.html#cv", link: "lk-1", sourceFile: "child.html" },
|
|
87
|
+
{ id: "ca", key: "child.html#ca", link: "lk-1", sourceFile: "child.html" },
|
|
88
|
+
];
|
|
89
|
+
const all = [...root, ...child];
|
|
90
|
+
|
|
91
|
+
it("does not pull a child file's same-id link into the root group", () => {
|
|
92
|
+
expect(linkedMembersOf(root[0] ?? video, all).map((el) => el.id)).toEqual(["v", "a"]);
|
|
93
|
+
expect(expandToLinkedMembers(["child.html#cv"], all)).toEqual(
|
|
94
|
+
new Set(["child.html#cv", "child.html#ca"]),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const media = (id: string, tag: string, start: number) =>
|
|
99
|
+
`<${tag} id="${id}" class="clip" src="${id}.mp4" data-link="lk-1" data-start="${start}" data-duration="4"></${tag}>`;
|
|
100
|
+
const inlineDoc = () => {
|
|
101
|
+
const doc = document.implementation.createHTMLDocument();
|
|
102
|
+
doc.body.innerHTML = `<div data-composition-id="main" data-duration="20">
|
|
103
|
+
${media("v", "video", 0)}${media("a", "audio", 0)}
|
|
104
|
+
<div id="child" data-composition-id="child" data-start="0" data-duration="10">
|
|
105
|
+
${media("cv", "video", 5)}${media("ca", "audio", 5)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>`;
|
|
108
|
+
return doc;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
it("does not pull an inline composition's same-id link into the root group", () => {
|
|
112
|
+
const rows = parseTimelineFromDOM(inlineDoc(), 20).filter((row) => row.link);
|
|
113
|
+
const byId = (id: string) => rows.find((row) => row.domId === id) ?? video;
|
|
114
|
+
expect(linkedMembersOf(byId("v"), rows).map((row) => row.domId)).toEqual(["v", "a"]);
|
|
115
|
+
expect(linkedMembersOf(byId("cv"), rows).map((row) => row.domId)).toEqual(["cv", "ca"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("keeps the inline composition apart on rows built from the runtime manifest", () => {
|
|
119
|
+
const doc = inlineDoc();
|
|
120
|
+
const rows = ["v", "a", "cv", "ca"].map((id, fallbackIndex) =>
|
|
121
|
+
createTimelineElementFromManifestClip({
|
|
122
|
+
clip: {
|
|
123
|
+
...{ id, label: id, start: 0, duration: 4, track: 0, kind: "video", tagName: "video" },
|
|
124
|
+
...{
|
|
125
|
+
compositionId: null,
|
|
126
|
+
parentCompositionId: null,
|
|
127
|
+
compositionSrc: null,
|
|
128
|
+
assetUrl: null,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
fallbackIndex,
|
|
132
|
+
doc,
|
|
133
|
+
hostEl: doc.getElementById(id),
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const [rootVideo] = rows;
|
|
137
|
+
expect(linkedMembersOf(rootVideo ?? video, rows).map((row) => row.domId)).toEqual(["v", "a"]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("linkedMembersOf", () => {
|
|
142
|
+
it("returns the group, or the clip alone when unlinked", () => {
|
|
143
|
+
expect(linkedMembersOf(video, elements).map((el) => el.id)).toEqual(["talk", "talk-audio"]);
|
|
144
|
+
expect(linkedMembersOf(plain, elements)).toEqual([plain]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("linkedGestureKeys", () => {
|
|
149
|
+
it("drags an unselected linked clip with its partner", () => {
|
|
150
|
+
expect(linkedGestureKeys(new Set(["title"]), video, elements, false)).toEqual(
|
|
151
|
+
new Set(["talk", "talk-audio"]),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps a selection that holds the grabbed clip and adds partners", () => {
|
|
156
|
+
expect(linkedGestureKeys(new Set(["talk", "title"]), video, elements, false)).toEqual(
|
|
157
|
+
new Set(["talk", "talk-audio", "title"]),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("Linked Selection off edits the grabbed clip alone without Alt", () => {
|
|
162
|
+
expect(linkedGestureKeys(new Set(["talk"]), video, elements, false, false)).toEqual(
|
|
163
|
+
new Set(["talk"]),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("Alt edits the grabbed clip alone", () => {
|
|
168
|
+
expect(linkedGestureKeys(new Set(["talk", "talk-audio"]), video, elements, true)).toEqual(
|
|
169
|
+
new Set(["talk"]),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("selectClipWithLinks", () => {
|
|
175
|
+
const linkedPair = [
|
|
176
|
+
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
177
|
+
{ id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
|
|
178
|
+
{ id: "title", tag: "div", start: 0, duration: 4, track: 2 },
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
async function store() {
|
|
182
|
+
const { usePlayerStore } = await import("../store/playerStore");
|
|
183
|
+
const { selectClipWithLinks } = await import("./timelineLinkSelection");
|
|
184
|
+
usePlayerStore.getState().setElements(linkedPair);
|
|
185
|
+
const click = (key: string, alt = false) =>
|
|
186
|
+
selectClipWithLinks(key, alt, usePlayerStore.getState().setSelectedElementId);
|
|
187
|
+
return { usePlayerStore, click };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("a click selects the whole pair; Alt-click then narrows to one member", async () => {
|
|
191
|
+
const { usePlayerStore, click } = await store();
|
|
192
|
+
click("talk");
|
|
193
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk", "talk-audio"]));
|
|
194
|
+
click("talk-audio", true);
|
|
195
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk-audio"]));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("a plain click collapses a larger selection to the clicked clip's link group", async () => {
|
|
199
|
+
const { usePlayerStore, click } = await store();
|
|
200
|
+
usePlayerStore.getState().setSelection(["talk", "talk-audio", "title"], "title");
|
|
201
|
+
click("talk");
|
|
202
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk", "talk-audio"]));
|
|
203
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("talk");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("Linked Selection off", () => {
|
|
208
|
+
const linkedPair = [
|
|
209
|
+
{ id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
|
|
210
|
+
{ id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
it("expands and lists nothing past the clip itself", () => {
|
|
214
|
+
expect(expandToLinkedMembers(["talk"], linkedPair, false)).toEqual(new Set(["talk"]));
|
|
215
|
+
expect(linkedMembersOf(linkedPair[0], linkedPair, false)).toEqual([linkedPair[0]]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("a click selects only the clicked clip", async () => {
|
|
219
|
+
const { usePlayerStore } = await import("../store/playerStore");
|
|
220
|
+
const { selectClipWithLinks } = await import("./timelineLinkSelection");
|
|
221
|
+
const { useLinkedClipPreferences } = await import("../../utils/linkedClipPreferences");
|
|
222
|
+
usePlayerStore.getState().setElements(linkedPair);
|
|
223
|
+
useLinkedClipPreferences.getState().setLinkedSelection(false);
|
|
224
|
+
try {
|
|
225
|
+
selectClipWithLinks("talk", false, usePlayerStore.getState().setSelectedElementId);
|
|
226
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk"]));
|
|
227
|
+
} finally {
|
|
228
|
+
useLinkedClipPreferences.getState().setLinkedSelection(true);
|
|
229
|
+
}
|
|
33
230
|
});
|
|
34
231
|
});
|
|
@@ -1,33 +1,80 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
type LinkScoped = Pick<TimelineElement, "sourceFile" | "compositionScope">;
|
|
4
|
+
type LinkedElement = Pick<TimelineElement, "id" | "key" | "link"> & LinkScoped;
|
|
5
|
+
|
|
6
|
+
const keyOf = (element: Pick<TimelineElement, "id" | "key">) => element.key ?? element.id;
|
|
7
|
+
|
|
8
|
+
const SOURCE_BASE = "https://project.invalid/";
|
|
9
|
+
const SOURCE_ORIGIN = new URL(SOURCE_BASE).origin;
|
|
10
|
+
|
|
11
|
+
export function mediaAssetIdentity(
|
|
12
|
+
element: Pick<TimelineElement, "src" | "sourceFile">,
|
|
13
|
+
): string | null {
|
|
14
|
+
const src = element.src?.trim();
|
|
4
15
|
if (!src) return null;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(src, new URL(element.sourceFile ?? "index.html", SOURCE_BASE));
|
|
18
|
+
if (url.origin !== SOURCE_ORIGIN) return `${url.origin}${url.pathname}${url.search}`;
|
|
19
|
+
return decodeURIComponent(`${url.origin}${url.pathname}`);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
8
23
|
}
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
|
|
13
|
-
): { muted: boolean; linked: boolean } {
|
|
14
|
-
return {
|
|
15
|
-
muted: audio.hidden === true || audio.audioGroupHidden === true,
|
|
16
|
-
linked: isLinkedVideoAudio(audio, elements),
|
|
17
|
-
};
|
|
25
|
+
function isLinked(element: Pick<TimelineElement, "link">): boolean {
|
|
26
|
+
return typeof element.link === "string" && element.link.length > 0;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
|
|
29
|
+
export function sharesLinkGroup(
|
|
30
|
+
a: Pick<TimelineElement, "link"> & LinkScoped,
|
|
31
|
+
b: Pick<TimelineElement, "link"> & LinkScoped,
|
|
24
32
|
): boolean {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
+
return isLinked(a) && a.link === b.link && sameCompositionScope(a, b);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function audioPillFlags(
|
|
37
|
+
audio: Pick<TimelineElement, "hidden" | "audioGroupHidden">,
|
|
38
|
+
_elements?: readonly unknown[],
|
|
39
|
+
): { muted: boolean } {
|
|
40
|
+
return { muted: audio.hidden === true || audio.audioGroupHidden === true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function linkedMembersOf<T extends LinkedElement>(
|
|
44
|
+
element: T,
|
|
45
|
+
elements: readonly T[],
|
|
46
|
+
linked = true,
|
|
47
|
+
): T[] {
|
|
48
|
+
if (!linked || !isLinked(element)) return [element];
|
|
49
|
+
const members = elements.filter((candidate) => sharesLinkGroup(candidate, element));
|
|
50
|
+
return members.some((member) => keyOf(member) === keyOf(element))
|
|
51
|
+
? members
|
|
52
|
+
: [element, ...members];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function expandToLinkedMembers(
|
|
56
|
+
keys: Iterable<string>,
|
|
57
|
+
elements: readonly LinkedElement[],
|
|
58
|
+
linked = true,
|
|
59
|
+
): Set<string> {
|
|
60
|
+
const expanded = new Set(keys);
|
|
61
|
+
if (!linked) return expanded;
|
|
62
|
+
const seeds = elements.filter((el) => expanded.has(keyOf(el)) && isLinked(el));
|
|
63
|
+
for (const element of elements) {
|
|
64
|
+
if (seeds.some((seed) => sharesLinkGroup(seed, element))) expanded.add(keyOf(element));
|
|
65
|
+
}
|
|
66
|
+
return expanded;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function linkedGestureKeys(
|
|
70
|
+
selected: ReadonlySet<string>,
|
|
71
|
+
grabbed: LinkedElement,
|
|
72
|
+
elements: readonly LinkedElement[],
|
|
73
|
+
altKey: boolean,
|
|
74
|
+
linked = true,
|
|
75
|
+
): Set<string> {
|
|
76
|
+
const grabbedKey = keyOf(grabbed);
|
|
77
|
+
if (altKey) return new Set([grabbedKey]);
|
|
78
|
+
const base = selected.has(grabbedKey) ? selected : [grabbedKey];
|
|
79
|
+
return expandToLinkedMembers(base, elements, linked);
|
|
33
80
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
|
+
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
6
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import { usePreviewIframeStore } from "../store/previewIframeStore";
|
|
8
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
9
|
+
|
|
10
|
+
const onNotice = vi.fn();
|
|
11
|
+
const setQuiet = vi.fn(async () => {});
|
|
12
|
+
|
|
13
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
14
|
+
useStudioShellContextOptional: () => null,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock("../../contexts/TimelineEditContext", () => ({
|
|
17
|
+
useTimelineEditContextOptional: () => ({ onSetElementAttributeQuiet: setQuiet, onNotice }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const harness = createHappyDomRootHarness();
|
|
21
|
+
const tour: TimelineElement = {
|
|
22
|
+
id: "tour",
|
|
23
|
+
tag: "video",
|
|
24
|
+
start: 0,
|
|
25
|
+
duration: 4,
|
|
26
|
+
track: 0,
|
|
27
|
+
hasAudio: true,
|
|
28
|
+
src: "tour.mp4",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function mountPreview(): void {
|
|
32
|
+
const iframe = document.createElement("iframe");
|
|
33
|
+
document.body.appendChild(iframe);
|
|
34
|
+
const doc = iframe.contentDocument;
|
|
35
|
+
if (!doc) throw new Error("iframe has no document");
|
|
36
|
+
doc.body.innerHTML =
|
|
37
|
+
'<video id="tour" src="tour.mp4" data-has-audio="true" data-start="0" data-duration="4"></video><audio id="voiceover" src="vo.wav" data-start="1" data-duration="2"></audio>';
|
|
38
|
+
usePreviewIframeStore.getState().setIframe(iframe);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function render(part: "normalize" | "duck") {
|
|
42
|
+
const host = document.createElement("div");
|
|
43
|
+
document.body.appendChild(host);
|
|
44
|
+
act(() =>
|
|
45
|
+
harness
|
|
46
|
+
.mount(host)
|
|
47
|
+
.render(<ClipMenuAudioItems part={part} element={tour} onClose={() => {}} />),
|
|
48
|
+
);
|
|
49
|
+
return host;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
usePreviewIframeStore.getState().setIframe(null);
|
|
54
|
+
vi.unstubAllGlobals();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("ClipMenuAudioItems in a host without Studio's shell", () => {
|
|
58
|
+
it("offers Normalize and Duck from the timeline session and the live preview", () => {
|
|
59
|
+
usePlayerStore.getState().beginTimelineSession("p1");
|
|
60
|
+
mountPreview();
|
|
61
|
+
expect(render("normalize").textContent).toContain("Normalize loudness");
|
|
62
|
+
expect(render("duck").textContent).toContain("Duck under voice");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("normalizes through the session's project and reports through onNotice", async () => {
|
|
66
|
+
usePlayerStore.getState().beginTimelineSession("p1");
|
|
67
|
+
mountPreview();
|
|
68
|
+
const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
|
|
69
|
+
const fetchSpy = vi.fn(async (_url: string) => Response.json({ plan }));
|
|
70
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
71
|
+
const button = render("normalize").querySelector("button");
|
|
72
|
+
await act(async () => button?.click());
|
|
73
|
+
await vi.waitFor(() => expect(onNotice).toHaveBeenCalled());
|
|
74
|
+
expect(String(fetchSpy.mock.calls[0]?.[0])).toContain("/api/projects/p1/loudness/normalize");
|
|
75
|
+
expect(setQuiet).toHaveBeenCalledWith(tour, "data-volume", "2", "Normalize loudness");
|
|
76
|
+
expect(onNotice).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -7,7 +7,12 @@ import type { TimelineElement } from "../store/timelineElement";
|
|
|
7
7
|
|
|
8
8
|
const showToast = vi.fn();
|
|
9
9
|
const setQuiet = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => {});
|
|
10
|
-
|
|
10
|
+
let previewDoc: Document | null = null;
|
|
11
|
+
const iframe = {
|
|
12
|
+
get contentDocument() {
|
|
13
|
+
return previewDoc;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
11
16
|
|
|
12
17
|
vi.mock("../../contexts/StudioContext", () => ({
|
|
13
18
|
useStudioShellContextOptional: () => ({
|
|
@@ -22,11 +27,13 @@ vi.mock("../../contexts/TimelineEditContext", () => ({
|
|
|
22
27
|
|
|
23
28
|
const harness = createHappyDomRootHarness();
|
|
24
29
|
|
|
25
|
-
function render(element: TimelineElement) {
|
|
30
|
+
function render(element: TimelineElement, part: "normalize" | "duck" = "normalize") {
|
|
26
31
|
const host = document.createElement("div");
|
|
27
32
|
document.body.appendChild(host);
|
|
28
33
|
act(() =>
|
|
29
|
-
harness
|
|
34
|
+
harness
|
|
35
|
+
.mount(host)
|
|
36
|
+
.render(<ClipMenuAudioItems part={part} element={element} onClose={() => {}} />),
|
|
30
37
|
);
|
|
31
38
|
return host;
|
|
32
39
|
}
|
|
@@ -50,6 +57,44 @@ describe("ClipMenuAudioItems", () => {
|
|
|
50
57
|
expect(host.textContent).toBe("");
|
|
51
58
|
});
|
|
52
59
|
|
|
60
|
+
describe("Duck under voice", () => {
|
|
61
|
+
const duckLabel = (host: HTMLElement) =>
|
|
62
|
+
[...host.querySelectorAll("button")].find((b) => b.textContent?.includes("Duck under voice"));
|
|
63
|
+
const compose = (body: string) => {
|
|
64
|
+
previewDoc = document.implementation.createHTMLDocument("c");
|
|
65
|
+
previewDoc.body.innerHTML = body;
|
|
66
|
+
};
|
|
67
|
+
const music: TimelineElement = { ...base, id: "music", tag: "audio", src: "music.mp3" };
|
|
68
|
+
|
|
69
|
+
it("is hidden on a lone video with sound", () => {
|
|
70
|
+
compose(
|
|
71
|
+
`<video id="a-roll" src="a.mp4" data-start="0" data-duration="4" data-has-audio="true"></video>`,
|
|
72
|
+
);
|
|
73
|
+
const host = render(
|
|
74
|
+
{ ...base, id: "a-roll", tag: "video", hasAudio: true, src: "a.mp4" },
|
|
75
|
+
"duck",
|
|
76
|
+
);
|
|
77
|
+
expect(duckLabel(host)).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("shows for a music bed with an overlapping voice", () => {
|
|
81
|
+
compose(`<audio id="music" src="music.mp3" data-start="0" data-duration="4"></audio>
|
|
82
|
+
<audio id="voiceover" src="vo.wav" data-start="1" data-duration="2"></audio>`);
|
|
83
|
+
const host = render(music, "duck");
|
|
84
|
+
expect(duckLabel(host)?.getAttribute("aria-checked")).toBe("false");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("stays available, checked, once ducked even with the voice gone", () => {
|
|
88
|
+
compose(
|
|
89
|
+
`<audio id="music" src="music.mp3" data-start="0" data-duration="4" data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.25}'></audio>`,
|
|
90
|
+
);
|
|
91
|
+
const host = render(music, "duck");
|
|
92
|
+
const item = duckLabel(host);
|
|
93
|
+
expect(item?.getAttribute("aria-checked")).toBe("true");
|
|
94
|
+
expect(item?.textContent).toBe("Duck under voice✓");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
53
98
|
it("normalizes a video with sound by writing data-volume as one edit", async () => {
|
|
54
99
|
const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
|
|
55
100
|
vi.stubGlobal(
|
|
@@ -104,22 +149,20 @@ describe("ClipMenuAudioItems", () => {
|
|
|
104
149
|
});
|
|
105
150
|
|
|
106
151
|
it("stops ducking at the first refused save and says why", async () => {
|
|
107
|
-
document.
|
|
108
|
-
|
|
109
|
-
if (!doc) throw new Error("fixture");
|
|
110
|
-
doc.body.innerHTML = `<audio id="music" src="music.mp3" data-start="0" data-duration="10"></audio>
|
|
152
|
+
previewDoc = document.implementation.createHTMLDocument("c");
|
|
153
|
+
previewDoc.body.innerHTML = `<audio id="music" src="music.mp3" data-start="0" data-duration="10"></audio>
|
|
111
154
|
<audio id="voiceover" src="vo.wav" data-start="1" data-duration="3"></audio>`;
|
|
112
155
|
setQuiet.mockResolvedValue({
|
|
113
156
|
status: "refused",
|
|
114
157
|
reason: "Cannot edit timeline while recording",
|
|
115
158
|
});
|
|
116
|
-
const host = render({ ...base, id: "music", tag: "audio", src: "music.mp3" });
|
|
159
|
+
const host = render({ ...base, id: "music", tag: "audio", src: "music.mp3" }, "duck");
|
|
117
160
|
await clickItem(host, "Duck under voice");
|
|
118
161
|
await vi.waitFor(() =>
|
|
119
162
|
expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error"),
|
|
120
163
|
);
|
|
121
164
|
expect(setQuiet).toHaveBeenCalledTimes(1);
|
|
122
165
|
expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Ducks under"), "info");
|
|
123
|
-
|
|
166
|
+
previewDoc = null;
|
|
124
167
|
});
|
|
125
168
|
});
|
|
@@ -2,6 +2,8 @@ import { formatAudioGain } from "@hyperframes/core/audio-gain";
|
|
|
2
2
|
import type { TimelineElement } from "../store/timelineElement";
|
|
3
3
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
4
4
|
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
5
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
6
|
+
import { useLivePreviewIframe } from "../store/previewIframeStore";
|
|
5
7
|
import {
|
|
6
8
|
clipHasSound,
|
|
7
9
|
normalizeToastText,
|
|
@@ -11,7 +13,7 @@ import {
|
|
|
11
13
|
VOLUME_LANE_REFUSAL,
|
|
12
14
|
volumeLaneOwnsGain,
|
|
13
15
|
} from "./clipMenuNormalize";
|
|
14
|
-
import {
|
|
16
|
+
import { offersDuck, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
|
|
15
17
|
|
|
16
18
|
const ITEM_CLASS =
|
|
17
19
|
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer";
|
|
@@ -23,21 +25,28 @@ const DUCK_TOAST: Record<DuckOutcome, string> = {
|
|
|
23
25
|
aborted: "Could not group the voices to duck under.",
|
|
24
26
|
};
|
|
25
27
|
|
|
26
|
-
/**
|
|
28
|
+
/** Normalize loudness (one-shot) or Duck under voice (toggle), placed separately in the sound group. */
|
|
27
29
|
export function ClipMenuAudioItems({
|
|
30
|
+
part,
|
|
28
31
|
element,
|
|
29
32
|
onClose,
|
|
30
33
|
}: {
|
|
34
|
+
part: "normalize" | "duck";
|
|
31
35
|
element: TimelineElement;
|
|
32
36
|
onClose: () => void;
|
|
33
37
|
}) {
|
|
34
38
|
const shell = useStudioShellContextOptional();
|
|
35
|
-
const { onSetElementAttributeQuiet, onGroupClips } = useTimelineEditContextOptional();
|
|
36
|
-
const
|
|
39
|
+
const { onSetElementAttributeQuiet, onGroupClips, onNotice } = useTimelineEditContextOptional();
|
|
40
|
+
const liveIframe = useLivePreviewIframe();
|
|
41
|
+
const sessionProjectId = usePlayerStore((s) => s.timelineProjectId);
|
|
42
|
+
const projectId = shell?.projectId ?? sessionProjectId;
|
|
43
|
+
const showToast = shell?.showToast ?? onNotice;
|
|
44
|
+
const doc = (shell?.previewIframeRef.current ?? liveIframe)?.contentDocument ?? null;
|
|
37
45
|
const bed = doc?.getElementById(element.domId ?? element.id) ?? null;
|
|
38
46
|
const ducked = bed ? readBedCarve(bed)?.enabled === true : false;
|
|
39
|
-
if (!clipHasSound(element) || !
|
|
40
|
-
|
|
47
|
+
if (!clipHasSound(element) || !projectId || !showToast || !onSetElementAttributeQuiet) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
41
50
|
|
|
42
51
|
const normalize = async () => {
|
|
43
52
|
onClose();
|
|
@@ -84,25 +93,24 @@ export function ClipMenuAudioItems({
|
|
|
84
93
|
showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info");
|
|
85
94
|
};
|
|
86
95
|
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
if (part === "normalize") {
|
|
97
|
+
return (
|
|
89
98
|
<button type="button" role="menuitem" className={ITEM_CLASS} onClick={() => void normalize()}>
|
|
90
99
|
<span>Normalize loudness</span>
|
|
91
100
|
</button>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
</>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (!offersDuck(doc, bed)) return null;
|
|
104
|
+
return (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
role="menuitemcheckbox"
|
|
108
|
+
aria-checked={ducked}
|
|
109
|
+
className={ITEM_CLASS}
|
|
110
|
+
onClick={() => void toggleDuck()}
|
|
111
|
+
>
|
|
112
|
+
<span>Duck under voice</span>
|
|
113
|
+
<span aria-hidden="true">{ducked ? "✓" : ""}</span>
|
|
114
|
+
</button>
|
|
107
115
|
);
|
|
108
116
|
}
|
|
@@ -83,6 +83,11 @@ export function duckVoiceSources(doc: Document, bed: Element): string[] {
|
|
|
83
83
|
.map((candidate) => candidate.id);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export function offersDuck(doc: Document | null, bed: Element | null): boolean {
|
|
87
|
+
if (!doc || !isDuckableBed(bed)) return false;
|
|
88
|
+
return readBedCarve(bed)?.enabled === true || duckVoiceSources(doc, bed).length > 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
86
91
|
async function writeMeasuredCarve(
|
|
87
92
|
doc: Document,
|
|
88
93
|
bed: Element,
|