@hyperframes/studio 0.8.108 → 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-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- 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 +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- 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/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- 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/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- 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/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -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 +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -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.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- 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 +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- 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 +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -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 +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- 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 +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- 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/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -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 +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- 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/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
canDetachAudio,
|
|
5
|
+
canLinkPair,
|
|
6
|
+
detachAudioInSource,
|
|
7
|
+
linkInSource,
|
|
8
|
+
findMergePair,
|
|
9
|
+
mergeAudioInSource,
|
|
10
|
+
pickDetachedAudioTrack,
|
|
11
|
+
removeElementInSource,
|
|
12
|
+
setLinkInSource,
|
|
13
|
+
sharesSourceFile,
|
|
14
|
+
} from "./mediaLinkEdits";
|
|
15
|
+
|
|
16
|
+
const automation = JSON.stringify({
|
|
17
|
+
version: 1,
|
|
18
|
+
lanes: [
|
|
19
|
+
{ target: "volume", points: [{ t: 0, v: 1 }] },
|
|
20
|
+
{ target: "rate", points: [{ t: 0, v: 2 }] },
|
|
21
|
+
],
|
|
22
|
+
}).replaceAll('"', """);
|
|
23
|
+
|
|
24
|
+
const VIDEO_TAG =
|
|
25
|
+
'<video id="talk" class="clip" src="assets/talk.mp4" data-start="2" data-duration="6" data-media-start="1" data-playback-rate="1" data-track-index="0" data-has-audio="true" data-volume="0.5" data-fade-in="0.3" data-fade-out="0.2" data-fx-chain="eq" data-fx-carve="carve" data-audio-group="voiceover" data-automation="' +
|
|
26
|
+
automation +
|
|
27
|
+
'"></video>';
|
|
28
|
+
|
|
29
|
+
const SOURCE = [
|
|
30
|
+
'<div data-composition-id="main" data-duration="10">',
|
|
31
|
+
` ${VIDEO_TAG}`,
|
|
32
|
+
' <audio id="music" class="clip" src="assets/bgm.mp3" data-start="0" data-duration="10" data-track-index="1"></audio>',
|
|
33
|
+
"</div>",
|
|
34
|
+
].join("\n");
|
|
35
|
+
|
|
36
|
+
const video = { id: "talk" };
|
|
37
|
+
|
|
38
|
+
function attrsOf(source: string, selector: string): Record<string, string> {
|
|
39
|
+
const el = new DOMParser().parseFromString(source, "text/html").querySelector(selector);
|
|
40
|
+
if (!el) throw new Error(`missing ${selector}`);
|
|
41
|
+
return Object.fromEntries(Array.from(el.attributes, (a) => [a.name, a.value]));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("detachAudioInSource", () => {
|
|
45
|
+
const result = detachAudioInSource(SOURCE, { target: video, videoId: "talk", track: 2 });
|
|
46
|
+
|
|
47
|
+
it("inserts a linked audio with the video's file, window and sound", () => {
|
|
48
|
+
expect(result).not.toBeNull();
|
|
49
|
+
const audio = attrsOf(result?.html ?? "", "audio#talk-audio");
|
|
50
|
+
expect(audio).toMatchObject({
|
|
51
|
+
src: "assets/talk.mp4",
|
|
52
|
+
"data-start": "2",
|
|
53
|
+
"data-duration": "6",
|
|
54
|
+
"data-media-start": "1",
|
|
55
|
+
"data-playback-rate": "1",
|
|
56
|
+
"data-track-index": "2",
|
|
57
|
+
"data-link": result?.linkId,
|
|
58
|
+
"data-sync-origin": result?.linkId,
|
|
59
|
+
"data-volume": "0.5",
|
|
60
|
+
"data-fade-in": "0.3",
|
|
61
|
+
"data-fade-out": "0.2",
|
|
62
|
+
"data-fx-chain": "eq",
|
|
63
|
+
"data-fx-carve": "carve",
|
|
64
|
+
"data-audio-group": "voiceover",
|
|
65
|
+
});
|
|
66
|
+
expect(audio["data-automation"]).toContain("volume");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("mutes the video, strips its sound attributes and links it", () => {
|
|
70
|
+
const after = attrsOf(result?.html ?? "", "video#talk");
|
|
71
|
+
expect(after).toHaveProperty("muted");
|
|
72
|
+
expect(after["data-link"]).toBe(result?.linkId);
|
|
73
|
+
expect(after["data-sync-origin"]).toBe(result?.linkId);
|
|
74
|
+
for (const gone of [
|
|
75
|
+
"data-has-audio",
|
|
76
|
+
"data-volume",
|
|
77
|
+
"data-fade-in",
|
|
78
|
+
"data-fade-out",
|
|
79
|
+
"data-fx-chain",
|
|
80
|
+
"data-fx-carve",
|
|
81
|
+
"data-audio-group",
|
|
82
|
+
]) {
|
|
83
|
+
expect(after).not.toHaveProperty(gone);
|
|
84
|
+
}
|
|
85
|
+
expect(after["data-automation"]).toContain("rate");
|
|
86
|
+
expect(after["data-automation"]).not.toContain("volume");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("suffixes the audio id when taken", () => {
|
|
90
|
+
const taken = SOURCE.replace('id="music"', 'id="talk-audio"');
|
|
91
|
+
expect(detachAudioInSource(taken, { target: video, videoId: "talk", track: 2 })?.audioId).toBe(
|
|
92
|
+
"talk-audio-2",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("mergeAudioInSource", () => {
|
|
98
|
+
it("round-trips detach back to the original attributes", () => {
|
|
99
|
+
const detached = detachAudioInSource(SOURCE, { target: video, videoId: "talk", track: 2 });
|
|
100
|
+
const merged = mergeAudioInSource(detached?.html ?? "", {
|
|
101
|
+
videoTarget: video,
|
|
102
|
+
audioTarget: { id: "talk-audio" },
|
|
103
|
+
});
|
|
104
|
+
const original = attrsOf(SOURCE, "video#talk");
|
|
105
|
+
const roundTrip = attrsOf(merged ?? "", "video#talk");
|
|
106
|
+
const lanes = (raw: string | undefined) =>
|
|
107
|
+
JSON.parse(raw ?? "{}").lanes.map((lane: { target: string }) => lane.target);
|
|
108
|
+
expect(lanes(roundTrip["data-automation"]).sort()).toEqual(
|
|
109
|
+
lanes(original["data-automation"]).sort(),
|
|
110
|
+
);
|
|
111
|
+
delete original["data-automation"];
|
|
112
|
+
delete roundTrip["data-automation"];
|
|
113
|
+
expect(roundTrip).toEqual(original);
|
|
114
|
+
expect(merged).not.toContain('<audio id="talk-audio"');
|
|
115
|
+
expect(merged).toContain('id="music"');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("converts a legacy a-roll + a-roll-audio pair into one video", () => {
|
|
119
|
+
const legacy = [
|
|
120
|
+
"<div>",
|
|
121
|
+
' <video id="a-roll" src="a.mp4" muted data-start="0" data-duration="4"></video>',
|
|
122
|
+
' <audio id="a-roll-audio" src="a.mp4" data-start="0" data-duration="4" data-volume="0.8"></audio>',
|
|
123
|
+
"</div>",
|
|
124
|
+
].join("\n");
|
|
125
|
+
const merged = mergeAudioInSource(legacy, {
|
|
126
|
+
videoTarget: { id: "a-roll" },
|
|
127
|
+
audioTarget: { id: "a-roll-audio" },
|
|
128
|
+
});
|
|
129
|
+
expect(merged).toBe(
|
|
130
|
+
[
|
|
131
|
+
"<div>",
|
|
132
|
+
' <video id="a-roll" src="a.mp4" data-start="0" data-duration="4" data-volume="0.8" data-has-audio="true"></video>',
|
|
133
|
+
"</div>",
|
|
134
|
+
].join("\n"),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("mergeAudioInSource refusals", () => {
|
|
140
|
+
const pair = (videoAttrs: string, audioAttrs: string) =>
|
|
141
|
+
[
|
|
142
|
+
"<div>",
|
|
143
|
+
` <video id="v" src="assets/one/talk.mp4" muted data-start="0" data-duration="4"${videoAttrs}></video>`,
|
|
144
|
+
` <audio id="a" src="assets/one/talk.mp4" data-start="0" data-duration="4"${audioAttrs}></audio>`,
|
|
145
|
+
"</div>",
|
|
146
|
+
].join("\n");
|
|
147
|
+
const merge = (source: string) =>
|
|
148
|
+
mergeAudioInSource(source, { videoTarget: { id: "v" }, audioTarget: { id: "a" } });
|
|
149
|
+
|
|
150
|
+
it("refuses an audio of a different file with the same name", () => {
|
|
151
|
+
expect(
|
|
152
|
+
merge(
|
|
153
|
+
pair("", "").replace('<audio id="a" src="assets/one/', '<audio id="a" src="assets/two/'),
|
|
154
|
+
),
|
|
155
|
+
).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("refuses when only one member is hidden, which would change what is heard", () => {
|
|
159
|
+
expect(merge(pair("", " data-hidden"))).toBeNull();
|
|
160
|
+
expect(merge(pair(" data-hidden", ""))).toBeNull();
|
|
161
|
+
expect(merge(pair(" data-hidden", " data-hidden"))).not.toBeNull();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("detachAudioInSource on a hidden video", () => {
|
|
166
|
+
it("keeps the detached sound silent", () => {
|
|
167
|
+
const hidden = SOURCE.replace('<video id="talk"', '<video id="talk" data-hidden');
|
|
168
|
+
const result = detachAudioInSource(hidden, { target: video, videoId: "talk", track: 2 });
|
|
169
|
+
expect(attrsOf(result?.html ?? "", "audio#talk-audio")).toHaveProperty("data-hidden");
|
|
170
|
+
expect(attrsOf(result?.html ?? "", "video#talk")).toHaveProperty("data-hidden");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("removeElementInSource", () => {
|
|
175
|
+
it("removes the element and its whole line", () => {
|
|
176
|
+
expect(removeElementInSource('<div>\n <audio id="a"></audio>\n</div>', { id: "a" })).toBe(
|
|
177
|
+
"<div>\n</div>",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("linkInSource", () => {
|
|
183
|
+
it("mints an id no element or link already uses", () => {
|
|
184
|
+
const src = '<video id="lk-1" data-link="lk-2"></video><audio id="a"></audio>';
|
|
185
|
+
expect(linkInSource(src, [{ id: "a" }])).toContain(
|
|
186
|
+
'<audio id="a" data-link="lk-3" data-sync-origin="lk-3">',
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("skips ids already used as a sync origin", () => {
|
|
191
|
+
const src = '<video id="v" data-sync-origin="lk-1"></video><audio id="a"></audio>';
|
|
192
|
+
expect(linkInSource(src, [{ id: "a" }])).toContain('data-link="lk-2"');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("linkInSource without a sync origin", () => {
|
|
197
|
+
it("writes only data-link", () => {
|
|
198
|
+
const src = '<video id="v"></video><audio id="a"></audio>';
|
|
199
|
+
expect(linkInSource(src, [{ id: "v" }, { id: "a" }], { syncOrigin: false })).toBe(
|
|
200
|
+
'<video id="v" data-link="lk-1"></video><audio id="a" data-link="lk-1"></audio>',
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("setLinkInSource", () => {
|
|
206
|
+
it("writes and removes data-link on every target", () => {
|
|
207
|
+
const src = '<video id="v"></video><audio id="a"></audio>';
|
|
208
|
+
const linked = setLinkInSource(src, [{ id: "v" }, { id: "a" }], "lk-1");
|
|
209
|
+
expect(linked).toBe(
|
|
210
|
+
'<video id="v" data-link="lk-1"></video><audio id="a" data-link="lk-1"></audio>',
|
|
211
|
+
);
|
|
212
|
+
expect(setLinkInSource(linked, [{ id: "v" }, { id: "a" }], null)).toBe(src);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("keeps the sync origin when unlinking", () => {
|
|
216
|
+
const linked = linkInSource('<video id="v"></video><audio id="a"></audio>', [
|
|
217
|
+
{ id: "v" },
|
|
218
|
+
{ id: "a" },
|
|
219
|
+
]);
|
|
220
|
+
const unlinked = setLinkInSource(linked, [{ id: "v" }, { id: "a" }], null);
|
|
221
|
+
expect(unlinked).toBe(
|
|
222
|
+
'<video id="v" data-sync-origin="lk-1"></video><audio id="a" data-sync-origin="lk-1"></audio>',
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const el = (id: string, tag: string, extra: Record<string, unknown> = {}) => ({
|
|
228
|
+
id,
|
|
229
|
+
tag,
|
|
230
|
+
src: "assets/talk.mp4",
|
|
231
|
+
start: 2,
|
|
232
|
+
duration: 6,
|
|
233
|
+
track: 0,
|
|
234
|
+
...extra,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("predicates", () => {
|
|
238
|
+
it("detaches only an unmuted video with declared audio", () => {
|
|
239
|
+
expect(canDetachAudio(el("v", "video", { hasAudio: true }))).toBe(true);
|
|
240
|
+
expect(canDetachAudio(el("v", "video", { hasAudio: true, muted: true }))).toBe(false);
|
|
241
|
+
expect(canDetachAudio(el("v", "video"))).toBe(false);
|
|
242
|
+
expect(canDetachAudio(el("a", "audio", { hasAudio: true }))).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("finds the merge pair from either member, linked or identically timed", () => {
|
|
246
|
+
const v = el("v", "video", { muted: true });
|
|
247
|
+
const a = el("a", "audio");
|
|
248
|
+
expect(findMergePair(v, [v, a])).toEqual({ video: v, audio: a });
|
|
249
|
+
expect(findMergePair(a, [v, a])).toEqual({ video: v, audio: a });
|
|
250
|
+
const drifted = el("a", "audio", { start: 3 });
|
|
251
|
+
expect(findMergePair(v, [v, drifted])).toBeNull();
|
|
252
|
+
const linked = [
|
|
253
|
+
el("v", "video", { muted: true, link: "lk-1" }),
|
|
254
|
+
el("a", "audio", { start: 3, link: "lk-1" }),
|
|
255
|
+
];
|
|
256
|
+
expect(findMergePair(linked[0] ?? v, linked)?.audio.id).toBe("a");
|
|
257
|
+
expect(findMergePair(el("v", "video"), [el("v", "video"), a])).toBeNull();
|
|
258
|
+
expect(findMergePair(v, [v, el("a", "audio", { src: "other.mp4" })])).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("links exactly one unlinked video and one audio, whatever their timing or file", () => {
|
|
262
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio")])).toBe(true);
|
|
263
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3, duration: 2 })])).toBe(true);
|
|
264
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio", { src: "other.mp3" })])).toBe(true);
|
|
265
|
+
expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio")])).toBe(false);
|
|
266
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio", { link: "y" })])).toBe(false);
|
|
267
|
+
expect(canLinkPair([el("v", "video"), el("w", "video")])).toBe(false);
|
|
268
|
+
expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio", { link: "x" })])).toBe(
|
|
269
|
+
false,
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("predicates compare the whole asset path", () => {
|
|
275
|
+
const one = { src: "assets/one/talk.mp4" };
|
|
276
|
+
const two = { src: "assets/two/talk.mp4" };
|
|
277
|
+
|
|
278
|
+
it("does not pair same-named files in different folders", () => {
|
|
279
|
+
const v = el("v", "video", { muted: true, ...one });
|
|
280
|
+
const a = el("a", "audio", two);
|
|
281
|
+
expect(findMergePair(v, [v, a])).toBeNull();
|
|
282
|
+
expect(sharesSourceFile([el("v", "video", one), el("a", "audio", two)])).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("resolves each src against its own source file, and links only inside one", () => {
|
|
286
|
+
const v = el("v", "video", { src: "../assets/talk.mp4", sourceFile: "scenes/a.html" });
|
|
287
|
+
const a = el("a", "audio", { src: "./assets/talk.mp4", sourceFile: "index.html" });
|
|
288
|
+
expect(canLinkPair([v, a])).toBe(false);
|
|
289
|
+
expect(sharesSourceFile([v, a])).toBe(false);
|
|
290
|
+
const sameFileAudio = { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" };
|
|
291
|
+
expect(sharesSourceFile([v, sameFileAudio])).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("does not take a same-id link from another source file as the merge partner", () => {
|
|
295
|
+
const v = el("v", "video", { muted: true, link: "lk-1", sourceFile: "index.html" });
|
|
296
|
+
const a = el("a", "audio", { start: 3, link: "lk-1", sourceFile: "child.html" });
|
|
297
|
+
expect(findMergePair(v, [v, a])).toBeNull();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("does not link or merge a pair split across inline compositions", () => {
|
|
301
|
+
const v = el("v", "video", { muted: true, compositionScope: "main" });
|
|
302
|
+
const a = el("a", "audio", { compositionScope: "child" });
|
|
303
|
+
expect(canLinkPair([v, a])).toBe(false);
|
|
304
|
+
expect(findMergePair(v, [v, a])).toBeNull();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("does not merge a hidden audio back into a visible video", () => {
|
|
308
|
+
const v = el("v", "video", { muted: true });
|
|
309
|
+
expect(findMergePair(v, [v, el("a", "audio", { hidden: true })])).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("pickDetachedAudioTrack", () => {
|
|
314
|
+
it("uses the first free audio track below the visual tracks", () => {
|
|
315
|
+
const elements = [el("v", "video", { track: 0 }), el("t", "div", { track: 1 })];
|
|
316
|
+
expect(pickDetachedAudioTrack(elements, { start: 2, duration: 6 })).toBe(2);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("reuses an audio track that is free in the clip's window", () => {
|
|
320
|
+
const elements = [
|
|
321
|
+
el("v", "video", { track: 0 }),
|
|
322
|
+
el("m", "audio", { track: 1, start: 0, duration: 2 }),
|
|
323
|
+
];
|
|
324
|
+
expect(pickDetachedAudioTrack(elements, { start: 2, duration: 6 })).toBe(1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("opens a new bottom track when every audio track is busy", () => {
|
|
328
|
+
const elements = [
|
|
329
|
+
el("v", "video", { track: 0 }),
|
|
330
|
+
el("m", "audio", { track: 1, start: 0, duration: 10 }),
|
|
331
|
+
];
|
|
332
|
+
expect(pickDetachedAudioTrack(elements, { start: 2, duration: 6 })).toBe(2);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
2
|
+
import {
|
|
3
|
+
MEDIA_LINK_ATTR,
|
|
4
|
+
SYNC_ORIGIN_ATTR,
|
|
5
|
+
mintLinkId,
|
|
6
|
+
sourceZeroTime,
|
|
7
|
+
} from "@hyperframes/core/media-link";
|
|
8
|
+
import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement";
|
|
9
|
+
import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink";
|
|
10
|
+
import {
|
|
11
|
+
applyPatchByTarget,
|
|
12
|
+
findTagByTarget,
|
|
13
|
+
readAttributeByTarget,
|
|
14
|
+
type PatchOperation,
|
|
15
|
+
type PatchTarget,
|
|
16
|
+
} from "../../utils/sourcePatcher";
|
|
17
|
+
import { collectHtmlIds } from "../../utils/studioHelpers";
|
|
18
|
+
import { generateId } from "../../utils/generateId";
|
|
19
|
+
import {
|
|
20
|
+
COPIED_TIMING_ATTRS,
|
|
21
|
+
MOVED_SOUND_ATTRS,
|
|
22
|
+
firstFreeName,
|
|
23
|
+
formatAttrs,
|
|
24
|
+
insertBeforeTarget,
|
|
25
|
+
readAuthoredSrc,
|
|
26
|
+
splitAutomation,
|
|
27
|
+
} from "./mediaAudioEdits";
|
|
28
|
+
|
|
29
|
+
const LINK_PROPERTY = MEDIA_LINK_ATTR.slice("data-".length);
|
|
30
|
+
const HIDDEN_ATTR = "data-hidden";
|
|
31
|
+
const SYNC_ORIGIN_PROPERTY = SYNC_ORIGIN_ATTR.slice("data-".length);
|
|
32
|
+
const SYNC_TOLERANCE_S = 1e-3;
|
|
33
|
+
|
|
34
|
+
const dataOp = (property: string, value: string | null): PatchOperation => ({
|
|
35
|
+
type: "attribute",
|
|
36
|
+
property: property.startsWith("data-") ? property.slice("data-".length) : property,
|
|
37
|
+
value,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const htmlOp = (property: string, value: string | null): PatchOperation => ({
|
|
41
|
+
type: "html-attribute",
|
|
42
|
+
property,
|
|
43
|
+
value,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function applyOps(source: string, target: PatchTarget, ops: readonly PatchOperation[]): string {
|
|
47
|
+
return ops.reduce((html, op) => applyPatchByTarget(html, target, op), source);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mintLinkIdForSource(source: string): string {
|
|
51
|
+
const links = Array.from(
|
|
52
|
+
source.matchAll(/\bdata-(?:link|sync-origin)=["']([^"']+)["']/g),
|
|
53
|
+
(match) => match[1] ?? "",
|
|
54
|
+
);
|
|
55
|
+
return mintLinkId([...collectHtmlIds(source), ...links]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Write (or with `null`, remove) `data-link` on every target. */
|
|
59
|
+
export function setLinkInSource(
|
|
60
|
+
source: string,
|
|
61
|
+
targets: readonly PatchTarget[],
|
|
62
|
+
linkId: string | null,
|
|
63
|
+
): string {
|
|
64
|
+
return targets.reduce(
|
|
65
|
+
(html, target) => applyPatchByTarget(html, target, dataOp(LINK_PROPERTY, linkId)),
|
|
66
|
+
source,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Link the targets under one fresh id, also their sync origin when they share a source file. */
|
|
71
|
+
export function linkInSource(
|
|
72
|
+
source: string,
|
|
73
|
+
targets: readonly PatchTarget[],
|
|
74
|
+
options: { syncOrigin: boolean } = { syncOrigin: true },
|
|
75
|
+
): string {
|
|
76
|
+
const linkId = mintLinkIdForSource(source);
|
|
77
|
+
const ops = options.syncOrigin
|
|
78
|
+
? [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]
|
|
79
|
+
: [dataOp(LINK_PROPERTY, linkId)];
|
|
80
|
+
return targets.reduce((html, target) => applyOps(html, target, ops), source);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Remove one element (open tag through its matching close tag) and its line's indentation. */
|
|
84
|
+
export function removeElementInSource(source: string, target: PatchTarget): string {
|
|
85
|
+
const match = findTagByTarget(source, target);
|
|
86
|
+
if (!match) return source;
|
|
87
|
+
const tagName = /^<([a-zA-Z][\w-]*)/.exec(match.tag)?.[1]?.toLowerCase() ?? "";
|
|
88
|
+
const closing = `</${tagName}>`;
|
|
89
|
+
const closeAt = match.tag.endsWith("/>")
|
|
90
|
+
? -1
|
|
91
|
+
: source.toLowerCase().indexOf(closing, match.end + 1);
|
|
92
|
+
let end = closeAt >= 0 ? closeAt + closing.length : match.end + 1;
|
|
93
|
+
let start = match.start;
|
|
94
|
+
const lineStart = source.lastIndexOf("\n", start) + 1;
|
|
95
|
+
if (/^[ \t]*$/.test(source.slice(lineStart, start)) && source[end] === "\n") {
|
|
96
|
+
start = lineStart;
|
|
97
|
+
end += 1;
|
|
98
|
+
}
|
|
99
|
+
return source.slice(0, start) + source.slice(end);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasBooleanAttr(source: string, target: PatchTarget, name: string): boolean {
|
|
103
|
+
const tag = findTagByTarget(source, target)?.tag ?? "";
|
|
104
|
+
return new RegExp(`\\s${name}(?=[\\s=/>]|$)`, "i").test(tag);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DetachAudioResult {
|
|
108
|
+
html: string;
|
|
109
|
+
audioId: string;
|
|
110
|
+
linkId: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Detach audio: insert a linked `<audio>` over the same file and window, move the
|
|
115
|
+
* sound attributes onto it, and mute the video. Renders identically.
|
|
116
|
+
*/
|
|
117
|
+
export function detachAudioInSource(
|
|
118
|
+
source: string,
|
|
119
|
+
input: { target: PatchTarget; videoId: string | null; track: number },
|
|
120
|
+
): DetachAudioResult | null {
|
|
121
|
+
const { target } = input;
|
|
122
|
+
if (!findTagByTarget(source, target)) return null;
|
|
123
|
+
const linkId = mintLinkIdForSource(source);
|
|
124
|
+
const audioId = firstFreeName(
|
|
125
|
+
`${input.videoId || "video"}-audio`,
|
|
126
|
+
new Set(collectHtmlIds(source)),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const moved: Array<[string, string]> = [];
|
|
130
|
+
const videoOps: PatchOperation[] = [htmlOp("muted", "true"), dataOp("has-audio", null)];
|
|
131
|
+
for (const name of MOVED_SOUND_ATTRS) {
|
|
132
|
+
const value = readAttributeByTarget(source, target, name);
|
|
133
|
+
if (value === undefined) continue;
|
|
134
|
+
moved.push([name, value]);
|
|
135
|
+
videoOps.push(dataOp(name, null));
|
|
136
|
+
}
|
|
137
|
+
const automation = readAttributeByTarget(source, target, HF_AUDIO_AUTOMATION_ATTR);
|
|
138
|
+
if (automation !== undefined) {
|
|
139
|
+
const { videoKeeps, audioTakes } = splitAutomation(automation);
|
|
140
|
+
moved.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
|
|
141
|
+
videoOps.push(dataOp(HF_AUDIO_AUTOMATION_ATTR, videoKeeps));
|
|
142
|
+
}
|
|
143
|
+
videoOps.push(dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId));
|
|
144
|
+
|
|
145
|
+
const timing: Array<[string, string]> = [];
|
|
146
|
+
for (const name of COPIED_TIMING_ATTRS) {
|
|
147
|
+
const value = readAttributeByTarget(source, target, name);
|
|
148
|
+
if (value !== undefined) timing.push([`data-${name}`, value]);
|
|
149
|
+
}
|
|
150
|
+
const attrs: Array<[string, string]> = [
|
|
151
|
+
["id", audioId],
|
|
152
|
+
["data-hf-id", `hf-${generateId()}`],
|
|
153
|
+
["class", "clip"],
|
|
154
|
+
["src", readAuthoredSrc(source, target)],
|
|
155
|
+
...timing,
|
|
156
|
+
["data-track-index", String(input.track)],
|
|
157
|
+
[MEDIA_LINK_ATTR, linkId],
|
|
158
|
+
[SYNC_ORIGIN_ATTR, linkId],
|
|
159
|
+
...moved,
|
|
160
|
+
];
|
|
161
|
+
if (hasBooleanAttr(source, target, "loop")) attrs.push(["loop", ""]);
|
|
162
|
+
if (hasBooleanAttr(source, target, HIDDEN_ATTR)) attrs.push([HIDDEN_ATTR, ""]);
|
|
163
|
+
const inserted = insertBeforeTarget(source, target, `<audio ${formatAttrs(attrs)}></audio>`);
|
|
164
|
+
return { html: applyOps(inserted, target, videoOps), audioId, linkId };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Merge back: move the audio's sound attributes onto its video, unmute the
|
|
169
|
+
* video, drop the link, and delete the audio. The inverse of detach.
|
|
170
|
+
*/
|
|
171
|
+
export function mergeAudioInSource(
|
|
172
|
+
source: string,
|
|
173
|
+
input: { videoTarget: PatchTarget; audioTarget: PatchTarget },
|
|
174
|
+
): string | null {
|
|
175
|
+
const { videoTarget, audioTarget } = input;
|
|
176
|
+
if (!findTagByTarget(source, videoTarget) || !findTagByTarget(source, audioTarget)) return null;
|
|
177
|
+
const assetOf = (target: PatchTarget) =>
|
|
178
|
+
mediaAssetIdentity({ src: readAuthoredSrc(source, target) });
|
|
179
|
+
const videoAsset = assetOf(videoTarget);
|
|
180
|
+
if (!videoAsset || videoAsset !== assetOf(audioTarget)) return null;
|
|
181
|
+
const hiddenOf = (target: PatchTarget) => hasBooleanAttr(source, target, HIDDEN_ATTR);
|
|
182
|
+
if (hiddenOf(videoTarget) !== hiddenOf(audioTarget)) return null;
|
|
183
|
+
const videoOps: PatchOperation[] = [];
|
|
184
|
+
for (const name of MOVED_SOUND_ATTRS) {
|
|
185
|
+
const value = readAttributeByTarget(source, audioTarget, name);
|
|
186
|
+
videoOps.push(dataOp(name, value ?? null));
|
|
187
|
+
}
|
|
188
|
+
const automation = readAttributeByTarget(source, audioTarget, HF_AUDIO_AUTOMATION_ATTR);
|
|
189
|
+
if (automation !== undefined) videoOps.push(dataOp(HF_AUDIO_AUTOMATION_ATTR, automation));
|
|
190
|
+
videoOps.push(
|
|
191
|
+
htmlOp("muted", null),
|
|
192
|
+
dataOp(LINK_PROPERTY, null),
|
|
193
|
+
dataOp(SYNC_ORIGIN_PROPERTY, null),
|
|
194
|
+
dataOp("has-audio", "true"),
|
|
195
|
+
);
|
|
196
|
+
return applyOps(removeElementInSource(source, audioTarget), videoTarget, videoOps);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
type TimedElement = Pick<
|
|
200
|
+
TimelineElement,
|
|
201
|
+
"id" | "key" | "tag" | "src" | "start" | "duration" | "playbackStart" | "playbackRate" | "link"
|
|
202
|
+
> &
|
|
203
|
+
Pick<TimelineElement, "muted" | "hasAudio" | "hidden" | "sourceFile" | "compositionScope">;
|
|
204
|
+
|
|
205
|
+
const tagOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
|
|
206
|
+
const keyOf = (el: Pick<TimelineElement, "id" | "key">) => el.key ?? el.id;
|
|
207
|
+
const near = (a: number, b: number) => Math.abs(a - b) <= SYNC_TOLERANCE_S;
|
|
208
|
+
|
|
209
|
+
function hasIdenticalTiming(a: TimedElement, b: TimedElement): boolean {
|
|
210
|
+
return (
|
|
211
|
+
near(a.start, b.start) &&
|
|
212
|
+
near(a.duration, b.duration) &&
|
|
213
|
+
near(a.playbackStart ?? 0, b.playbackStart ?? 0) &&
|
|
214
|
+
near(a.playbackRate ?? 1, b.playbackRate ?? 1)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const sameAssetInScope = (a: TimedElement, b: TimedElement) => {
|
|
219
|
+
const asset = mediaAssetIdentity(a);
|
|
220
|
+
return asset !== null && asset === mediaAssetIdentity(b) && sameCompositionScope(a, b);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** A video whose sound is on the video itself: what Detach audio acts on. */
|
|
224
|
+
export function canDetachAudio(el: TimedElement): boolean {
|
|
225
|
+
return tagOf(el) === "video" && el.muted !== true && el.hasAudio === true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The (muted video, audio) pair Merge back acts on, found from either member:
|
|
230
|
+
* same file, and linked to each other or with identical timing.
|
|
231
|
+
*/
|
|
232
|
+
export function findMergePair<T extends TimedElement>(
|
|
233
|
+
element: T,
|
|
234
|
+
elements: readonly T[],
|
|
235
|
+
): { video: T; audio: T } | null {
|
|
236
|
+
const isVideo = tagOf(element) === "video";
|
|
237
|
+
if (!isVideo && tagOf(element) !== "audio") return null;
|
|
238
|
+
const partnerTag = isVideo ? "audio" : "video";
|
|
239
|
+
const candidates = elements.filter(
|
|
240
|
+
(el) =>
|
|
241
|
+
keyOf(el) !== keyOf(element) && tagOf(el) === partnerTag && sameAssetInScope(el, element),
|
|
242
|
+
);
|
|
243
|
+
const partner =
|
|
244
|
+
candidates.find((el) => sharesLinkGroup(el, element)) ??
|
|
245
|
+
candidates.find((el) => hasIdenticalTiming(el, element));
|
|
246
|
+
if (!partner) return null;
|
|
247
|
+
const video = isVideo ? element : partner;
|
|
248
|
+
const audio = isVideo ? partner : element;
|
|
249
|
+
if ((video.hidden === true) !== (audio.hidden === true)) return null;
|
|
250
|
+
return video.muted === true ? { video, audio } : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One video and one audio in one composition, neither linked: timing and file don't matter. */
|
|
254
|
+
export function canLinkPair(selected: readonly TimedElement[]): boolean {
|
|
255
|
+
if (selected.length !== 2) return false;
|
|
256
|
+
const [a, b] = selected;
|
|
257
|
+
if (!a || !b || a.link || b.link) return false;
|
|
258
|
+
const tags = new Set([tagOf(a), tagOf(b)]);
|
|
259
|
+
return tags.has("video") && tags.has("audio") && sameCompositionScope(a, b);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function sharesSourceFile(selected: readonly TimedElement[]): boolean {
|
|
263
|
+
const [a, b] = selected;
|
|
264
|
+
return selected.length === 2 && !!a && !!b && sameAssetInScope(a, b);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function isPairInSync(a: TimedElement, b: TimedElement): boolean {
|
|
268
|
+
const timing = (el: TimedElement) => ({
|
|
269
|
+
start: el.start,
|
|
270
|
+
mediaStart: el.playbackStart ?? 0,
|
|
271
|
+
playbackRate: el.playbackRate ?? 1,
|
|
272
|
+
});
|
|
273
|
+
const [x, y] = [timing(a), timing(b)];
|
|
274
|
+
return near(x.playbackRate, y.playbackRate) && near(sourceZeroTime(x), sourceZeroTime(y));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type TrackedElement = Pick<TimelineElement, "tag" | "track" | "start" | "duration"> & {
|
|
278
|
+
authoredTrack?: number;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The detached audio's track: the first audio-only track below every visual
|
|
283
|
+
* track that is free for the clip's window, else a new track at the bottom.
|
|
284
|
+
*/
|
|
285
|
+
export function pickDetachedAudioTrack(
|
|
286
|
+
elements: readonly TrackedElement[],
|
|
287
|
+
window: { start: number; duration: number },
|
|
288
|
+
): number {
|
|
289
|
+
const trackOf = (el: TrackedElement) => el.authoredTrack ?? el.track;
|
|
290
|
+
const visualTracks = elements.filter((el) => tagOf(el) !== "audio").map(trackOf);
|
|
291
|
+
const lowestAudio = Math.max(-1, ...visualTracks) + 1;
|
|
292
|
+
const end = window.start + window.duration;
|
|
293
|
+
const allTracks = elements.map(trackOf);
|
|
294
|
+
const bottom = Math.max(lowestAudio - 1, ...allTracks) + 1;
|
|
295
|
+
for (let track = lowestAudio; track < bottom; track += 1) {
|
|
296
|
+
const occupants = elements.filter((el) => trackOf(el) === track);
|
|
297
|
+
const free = occupants.every(
|
|
298
|
+
(el) => tagOf(el) === "audio" && (el.start >= end || el.start + el.duration <= window.start),
|
|
299
|
+
);
|
|
300
|
+
if (free) return track;
|
|
301
|
+
}
|
|
302
|
+
return bottom;
|
|
303
|
+
}
|
|
@@ -105,7 +105,14 @@ function fixture(
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
|
|
108
|
-
const
|
|
108
|
+
const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
|
|
109
|
+
const event = new MouseEvent(type, {
|
|
110
|
+
bubbles: true,
|
|
111
|
+
cancelable: true,
|
|
112
|
+
button: 0,
|
|
113
|
+
buttons,
|
|
114
|
+
...init,
|
|
115
|
+
});
|
|
109
116
|
act(() => {
|
|
110
117
|
target.dispatchEvent(event);
|
|
111
118
|
});
|
|
@@ -879,10 +879,11 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
|
|
|
879
879
|
htmlOp("muted", "true"),
|
|
880
880
|
dataOp("has-audio", null),
|
|
881
881
|
dataOp("volume", null),
|
|
882
|
-
dataOp("link", "
|
|
882
|
+
dataOp("link", "lk-1"),
|
|
883
|
+
dataOp("sync-origin", "lk-1"),
|
|
883
884
|
]);
|
|
884
885
|
const source =
|
|
885
|
-
'<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" muted data-start="2" data-duration="3" data-track-index="0" data-link="
|
|
886
|
+
'<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" muted data-start="2" data-duration="3" data-track-index="0" data-link="lk-1"></video></div>';
|
|
886
887
|
const prepared = options?.prepareContent?.(source) ?? "";
|
|
887
888
|
const audio = new DOMParser()
|
|
888
889
|
.parseFromString(prepared, "text/html")
|
|
@@ -890,7 +891,7 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
|
|
|
890
891
|
expect(audio?.getAttribute("src")).toBe("assets/intro-loop.mp4");
|
|
891
892
|
expect(audio?.getAttribute("data-start")).toBe("2");
|
|
892
893
|
expect(audio?.getAttribute("data-duration")).toBe("3");
|
|
893
|
-
expect(audio?.getAttribute("data-link")).toBe("
|
|
894
|
+
expect(audio?.getAttribute("data-link")).toBe("lk-1");
|
|
894
895
|
expect(audio?.getAttribute("data-volume")).toBe("0.5");
|
|
895
896
|
expect(text).toContain("Background removed. Sound kept on a linked audio track.");
|
|
896
897
|
});
|