@hyperframes/studio 0.8.107 → 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-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.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-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -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/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/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/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -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/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/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- 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 +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -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 +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- 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 +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- 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 +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/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 +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -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 +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -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/useClipToolState.ts +92 -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/lib/timelineElementHelpers.ts +9 -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/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.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/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -12,7 +12,9 @@ import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
|
|
|
12
12
|
import {
|
|
13
13
|
escapeHtmlAttribute,
|
|
14
14
|
findTagByTarget,
|
|
15
|
+
applyPatchByTarget,
|
|
15
16
|
readAttributeByTarget,
|
|
17
|
+
unescapeHtmlAttribute,
|
|
16
18
|
type PatchOperation,
|
|
17
19
|
type PatchTarget,
|
|
18
20
|
} from "../../utils/sourcePatcher";
|
|
@@ -21,10 +23,17 @@ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
|
21
23
|
import { buildDomEditPatchTarget, type DomEditSelection } from "./domEditing";
|
|
22
24
|
import { generateId } from "../../utils/generateId";
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
import {
|
|
27
|
+
MEDIA_LINK_ATTR,
|
|
28
|
+
SYNC_ORIGIN_ATTR,
|
|
29
|
+
mintLinkId as mintLinkIdFrom,
|
|
30
|
+
} from "@hyperframes/core/media-link";
|
|
25
31
|
const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
|
|
26
32
|
|
|
27
|
-
const
|
|
33
|
+
const SOUND_NOT_KEPT_MESSAGE =
|
|
34
|
+
"Background removed, but the sound could not be kept on a linked audio track. Undo to restore the original clip.";
|
|
35
|
+
|
|
36
|
+
export const MOVED_SOUND_ATTRS = [
|
|
28
37
|
"data-volume",
|
|
29
38
|
HF_AUDIO_FADE_IN_ATTR,
|
|
30
39
|
HF_AUDIO_FADE_OUT_ATTR,
|
|
@@ -33,7 +42,7 @@ const MOVED_SOUND_ATTRS = [
|
|
|
33
42
|
HF_AUDIO_GROUP_ATTR,
|
|
34
43
|
];
|
|
35
44
|
|
|
36
|
-
const COPIED_TIMING_ATTRS = [
|
|
45
|
+
export const COPIED_TIMING_ATTRS = [
|
|
37
46
|
"start",
|
|
38
47
|
"end",
|
|
39
48
|
"duration",
|
|
@@ -86,7 +95,7 @@ export function cutoutOps(input: { isVideo: boolean; cutoutSrc: string }): Patch
|
|
|
86
95
|
return input.isVideo ? [src, htmlAttr("muted", "true"), dataAttr("has-audio", null)] : [src];
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
function firstFreeName(base: string, taken: Set<string>): string {
|
|
98
|
+
export function firstFreeName(base: string, taken: Set<string>): string {
|
|
90
99
|
if (!taken.has(base)) return base;
|
|
91
100
|
let n = 2;
|
|
92
101
|
while (taken.has(`${base}-${n}`)) n += 1;
|
|
@@ -95,12 +104,16 @@ function firstFreeName(base: string, taken: Set<string>): string {
|
|
|
95
104
|
|
|
96
105
|
export function mintLinkId(doc: Document): string {
|
|
97
106
|
const taken = new Set<string>();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
107
|
+
const selector = `[id], [${MEDIA_LINK_ATTR}], [${SYNC_ORIGIN_ATTR}]`;
|
|
108
|
+
for (const el of Array.from(doc.querySelectorAll(selector))) {
|
|
109
|
+
for (const value of [
|
|
110
|
+
el.id,
|
|
111
|
+
el.getAttribute(MEDIA_LINK_ATTR),
|
|
112
|
+
el.getAttribute(SYNC_ORIGIN_ATTR),
|
|
113
|
+
])
|
|
114
|
+
if (value) taken.add(value);
|
|
102
115
|
}
|
|
103
|
-
return
|
|
116
|
+
return mintLinkIdFrom(taken);
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
function firstFreeTrackIndex(source: string): number {
|
|
@@ -112,7 +125,7 @@ function firstFreeTrackIndex(source: string): number {
|
|
|
112
125
|
return track;
|
|
113
126
|
}
|
|
114
127
|
|
|
115
|
-
function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
|
|
128
|
+
export function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
|
|
116
129
|
let lanes: HfAutomationLane[];
|
|
117
130
|
try {
|
|
118
131
|
lanes = parseAutomation(raw).lanes;
|
|
@@ -127,11 +140,20 @@ function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes:
|
|
|
127
140
|
};
|
|
128
141
|
}
|
|
129
142
|
|
|
130
|
-
function formatAttrs(attrs: Array<[string, string]>): string {
|
|
143
|
+
export function formatAttrs(attrs: Array<[string, string]>): string {
|
|
131
144
|
return attrs.map(([name, value]) => `${name}="${escapeHtmlAttribute(value)}"`).join(" ");
|
|
132
145
|
}
|
|
133
146
|
|
|
134
|
-
function
|
|
147
|
+
export function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
148
|
+
const tag = findTagByTarget(source, target);
|
|
149
|
+
if (!tag) return "";
|
|
150
|
+
const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
|
|
151
|
+
if (own?.[2]) return unescapeHtmlAttribute(own[2]);
|
|
152
|
+
const firstSource = /^\s*<source\b[^>]*?\bsrc=(["'])([^"']*)\1/i.exec(source.slice(tag.end + 1));
|
|
153
|
+
return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
|
|
135
157
|
const match = findTagByTarget(source, target);
|
|
136
158
|
if (!match) return source;
|
|
137
159
|
const lineStart = source.lastIndexOf("\n", match.start) + 1;
|
|
@@ -145,6 +167,7 @@ function insertBeforeTarget(source: string, target: PatchTarget, markup: string)
|
|
|
145
167
|
export interface KeepSoundCutoutEdit {
|
|
146
168
|
ops: PatchOperation[];
|
|
147
169
|
prepareContent: (source: string) => string;
|
|
170
|
+
audioInserted: () => boolean;
|
|
148
171
|
}
|
|
149
172
|
|
|
150
173
|
export function buildKeepSoundCutoutEdit(input: {
|
|
@@ -155,10 +178,11 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
155
178
|
}): KeepSoundCutoutEdit {
|
|
156
179
|
const { video, target } = input;
|
|
157
180
|
const linkId = mintLinkId(video.ownerDocument);
|
|
158
|
-
|
|
181
|
+
let audioInserted = false;
|
|
159
182
|
|
|
160
183
|
const movedSound: Array<[string, string]> = [];
|
|
161
|
-
const
|
|
184
|
+
const cutoutSrcOp = htmlAttr("src", input.cutoutSrc);
|
|
185
|
+
const ops: PatchOperation[] = [htmlAttr("muted", "true"), dataAttr("has-audio", null)];
|
|
162
186
|
for (const name of MOVED_SOUND_ATTRS) {
|
|
163
187
|
const value = video.getAttribute(name);
|
|
164
188
|
if (value === null) continue;
|
|
@@ -171,9 +195,13 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
171
195
|
movedSound.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
|
|
172
196
|
ops.push(dataAttr(stripDataPrefix(HF_AUDIO_AUTOMATION_ATTR), videoKeeps));
|
|
173
197
|
}
|
|
174
|
-
ops.push(
|
|
198
|
+
ops.push(
|
|
199
|
+
dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId),
|
|
200
|
+
dataAttr(stripDataPrefix(SYNC_ORIGIN_ATTR), linkId),
|
|
201
|
+
);
|
|
175
202
|
|
|
176
203
|
const prepareContent = (source: string): string => {
|
|
204
|
+
const originalSrc = readAuthoredSrc(source, target);
|
|
177
205
|
const audioId = firstFreeName(
|
|
178
206
|
`${input.videoId || "video"}-audio`,
|
|
179
207
|
new Set(collectHtmlIds(source)),
|
|
@@ -191,12 +219,18 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
191
219
|
...timing,
|
|
192
220
|
["data-track-index", String(firstFreeTrackIndex(source))],
|
|
193
221
|
[MEDIA_LINK_ATTR, linkId],
|
|
222
|
+
[SYNC_ORIGIN_ATTR, linkId],
|
|
194
223
|
...movedSound,
|
|
195
224
|
];
|
|
196
|
-
|
|
225
|
+
if (video.hasAttribute("loop")) attrs.push(["loop", ""]);
|
|
226
|
+
if (video.hasAttribute("data-hidden")) attrs.push(["data-hidden", ""]);
|
|
227
|
+
const cut = applyPatchByTarget(source, target, cutoutSrcOp);
|
|
228
|
+
const inserted = insertBeforeTarget(cut, target, `<audio ${formatAttrs(attrs)}></audio>`);
|
|
229
|
+
audioInserted = inserted !== cut;
|
|
230
|
+
return inserted;
|
|
197
231
|
};
|
|
198
232
|
|
|
199
|
-
return { ops, prepareContent };
|
|
233
|
+
return { ops, prepareContent, audioInserted: () => audioInserted };
|
|
200
234
|
}
|
|
201
235
|
|
|
202
236
|
export interface MediaEditContext {
|
|
@@ -231,23 +265,31 @@ export async function commitCutout(
|
|
|
231
265
|
const isVideo = isVideoSelection(ctx.element);
|
|
232
266
|
const label = "Remove background";
|
|
233
267
|
const keep = isVideo && keepSound;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
268
|
+
if (!keep) {
|
|
269
|
+
const applied = await ctx.commit(ctx.element, cutoutOps({ isVideo, cutoutSrc }), { label });
|
|
270
|
+
if (!applied) throw new Error("Couldn't apply the cutout");
|
|
271
|
+
return "Applied cutout";
|
|
272
|
+
}
|
|
273
|
+
const { landed, soundKept } = await commitKeepSoundCutout(ctx, cutoutSrc, label);
|
|
237
274
|
if (!landed) throw new Error("Couldn't apply the cutout");
|
|
238
|
-
|
|
275
|
+
if (!soundKept) throw new Error(SOUND_NOT_KEPT_MESSAGE);
|
|
276
|
+
return SOUND_KEPT_ON_LINKED_AUDIO_STAGE;
|
|
239
277
|
}
|
|
240
278
|
|
|
241
|
-
function commitKeepSoundCutout(
|
|
279
|
+
async function commitKeepSoundCutout(
|
|
242
280
|
ctx: MediaEditContext,
|
|
243
281
|
cutoutSrc: string,
|
|
244
282
|
label: string,
|
|
245
|
-
): Promise<boolean> {
|
|
283
|
+
): Promise<{ landed: boolean; soundKept: boolean }> {
|
|
246
284
|
const edit = buildKeepSoundCutoutEdit({
|
|
247
285
|
video: ctx.element.element,
|
|
248
286
|
videoId: ctx.element.id ?? null,
|
|
249
287
|
target: buildDomEditPatchTarget(ctx.element),
|
|
250
288
|
cutoutSrc,
|
|
251
289
|
});
|
|
252
|
-
|
|
290
|
+
const landed = await ctx.commit(ctx.element, edit.ops, {
|
|
291
|
+
label,
|
|
292
|
+
prepareContent: edit.prepareContent,
|
|
293
|
+
});
|
|
294
|
+
return { landed, soundKept: landed && edit.audioInserted() };
|
|
253
295
|
}
|
|
@@ -0,0 +1,321 @@
|
|
|
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
|
+
} from "./mediaLinkEdits";
|
|
14
|
+
|
|
15
|
+
const automation = JSON.stringify({
|
|
16
|
+
version: 1,
|
|
17
|
+
lanes: [
|
|
18
|
+
{ target: "volume", points: [{ t: 0, v: 1 }] },
|
|
19
|
+
{ target: "rate", points: [{ t: 0, v: 2 }] },
|
|
20
|
+
],
|
|
21
|
+
}).replaceAll('"', """);
|
|
22
|
+
|
|
23
|
+
const VIDEO_TAG =
|
|
24
|
+
'<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="' +
|
|
25
|
+
automation +
|
|
26
|
+
'"></video>';
|
|
27
|
+
|
|
28
|
+
const SOURCE = [
|
|
29
|
+
'<div data-composition-id="main" data-duration="10">',
|
|
30
|
+
` ${VIDEO_TAG}`,
|
|
31
|
+
' <audio id="music" class="clip" src="assets/bgm.mp3" data-start="0" data-duration="10" data-track-index="1"></audio>',
|
|
32
|
+
"</div>",
|
|
33
|
+
].join("\n");
|
|
34
|
+
|
|
35
|
+
const video = { id: "talk" };
|
|
36
|
+
|
|
37
|
+
function attrsOf(source: string, selector: string): Record<string, string> {
|
|
38
|
+
const el = new DOMParser().parseFromString(source, "text/html").querySelector(selector);
|
|
39
|
+
if (!el) throw new Error(`missing ${selector}`);
|
|
40
|
+
return Object.fromEntries(Array.from(el.attributes, (a) => [a.name, a.value]));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("detachAudioInSource", () => {
|
|
44
|
+
const result = detachAudioInSource(SOURCE, { target: video, videoId: "talk", track: 2 });
|
|
45
|
+
|
|
46
|
+
it("inserts a linked audio with the video's file, window and sound", () => {
|
|
47
|
+
expect(result).not.toBeNull();
|
|
48
|
+
const audio = attrsOf(result?.html ?? "", "audio#talk-audio");
|
|
49
|
+
expect(audio).toMatchObject({
|
|
50
|
+
src: "assets/talk.mp4",
|
|
51
|
+
"data-start": "2",
|
|
52
|
+
"data-duration": "6",
|
|
53
|
+
"data-media-start": "1",
|
|
54
|
+
"data-playback-rate": "1",
|
|
55
|
+
"data-track-index": "2",
|
|
56
|
+
"data-link": result?.linkId,
|
|
57
|
+
"data-sync-origin": result?.linkId,
|
|
58
|
+
"data-volume": "0.5",
|
|
59
|
+
"data-fade-in": "0.3",
|
|
60
|
+
"data-fade-out": "0.2",
|
|
61
|
+
"data-fx-chain": "eq",
|
|
62
|
+
"data-fx-carve": "carve",
|
|
63
|
+
"data-audio-group": "voiceover",
|
|
64
|
+
});
|
|
65
|
+
expect(audio["data-automation"]).toContain("volume");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("mutes the video, strips its sound attributes and links it", () => {
|
|
69
|
+
const after = attrsOf(result?.html ?? "", "video#talk");
|
|
70
|
+
expect(after).toHaveProperty("muted");
|
|
71
|
+
expect(after["data-link"]).toBe(result?.linkId);
|
|
72
|
+
expect(after["data-sync-origin"]).toBe(result?.linkId);
|
|
73
|
+
for (const gone of [
|
|
74
|
+
"data-has-audio",
|
|
75
|
+
"data-volume",
|
|
76
|
+
"data-fade-in",
|
|
77
|
+
"data-fade-out",
|
|
78
|
+
"data-fx-chain",
|
|
79
|
+
"data-fx-carve",
|
|
80
|
+
"data-audio-group",
|
|
81
|
+
]) {
|
|
82
|
+
expect(after).not.toHaveProperty(gone);
|
|
83
|
+
}
|
|
84
|
+
expect(after["data-automation"]).toContain("rate");
|
|
85
|
+
expect(after["data-automation"]).not.toContain("volume");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("suffixes the audio id when taken", () => {
|
|
89
|
+
const taken = SOURCE.replace('id="music"', 'id="talk-audio"');
|
|
90
|
+
expect(detachAudioInSource(taken, { target: video, videoId: "talk", track: 2 })?.audioId).toBe(
|
|
91
|
+
"talk-audio-2",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("mergeAudioInSource", () => {
|
|
97
|
+
it("round-trips detach back to the original attributes", () => {
|
|
98
|
+
const detached = detachAudioInSource(SOURCE, { target: video, videoId: "talk", track: 2 });
|
|
99
|
+
const merged = mergeAudioInSource(detached?.html ?? "", {
|
|
100
|
+
videoTarget: video,
|
|
101
|
+
audioTarget: { id: "talk-audio" },
|
|
102
|
+
});
|
|
103
|
+
const original = attrsOf(SOURCE, "video#talk");
|
|
104
|
+
const roundTrip = attrsOf(merged ?? "", "video#talk");
|
|
105
|
+
const lanes = (raw: string | undefined) =>
|
|
106
|
+
JSON.parse(raw ?? "{}").lanes.map((lane: { target: string }) => lane.target);
|
|
107
|
+
expect(lanes(roundTrip["data-automation"]).sort()).toEqual(
|
|
108
|
+
lanes(original["data-automation"]).sort(),
|
|
109
|
+
);
|
|
110
|
+
delete original["data-automation"];
|
|
111
|
+
delete roundTrip["data-automation"];
|
|
112
|
+
expect(roundTrip).toEqual(original);
|
|
113
|
+
expect(merged).not.toContain('<audio id="talk-audio"');
|
|
114
|
+
expect(merged).toContain('id="music"');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("converts a legacy a-roll + a-roll-audio pair into one video", () => {
|
|
118
|
+
const legacy = [
|
|
119
|
+
"<div>",
|
|
120
|
+
' <video id="a-roll" src="a.mp4" muted data-start="0" data-duration="4"></video>',
|
|
121
|
+
' <audio id="a-roll-audio" src="a.mp4" data-start="0" data-duration="4" data-volume="0.8"></audio>',
|
|
122
|
+
"</div>",
|
|
123
|
+
].join("\n");
|
|
124
|
+
const merged = mergeAudioInSource(legacy, {
|
|
125
|
+
videoTarget: { id: "a-roll" },
|
|
126
|
+
audioTarget: { id: "a-roll-audio" },
|
|
127
|
+
});
|
|
128
|
+
expect(merged).toBe(
|
|
129
|
+
[
|
|
130
|
+
"<div>",
|
|
131
|
+
' <video id="a-roll" src="a.mp4" data-start="0" data-duration="4" data-volume="0.8" data-has-audio="true"></video>',
|
|
132
|
+
"</div>",
|
|
133
|
+
].join("\n"),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("mergeAudioInSource refusals", () => {
|
|
139
|
+
const pair = (videoAttrs: string, audioAttrs: string) =>
|
|
140
|
+
[
|
|
141
|
+
"<div>",
|
|
142
|
+
` <video id="v" src="assets/one/talk.mp4" muted data-start="0" data-duration="4"${videoAttrs}></video>`,
|
|
143
|
+
` <audio id="a" src="assets/one/talk.mp4" data-start="0" data-duration="4"${audioAttrs}></audio>`,
|
|
144
|
+
"</div>",
|
|
145
|
+
].join("\n");
|
|
146
|
+
const merge = (source: string) =>
|
|
147
|
+
mergeAudioInSource(source, { videoTarget: { id: "v" }, audioTarget: { id: "a" } });
|
|
148
|
+
|
|
149
|
+
it("refuses an audio of a different file with the same name", () => {
|
|
150
|
+
expect(
|
|
151
|
+
merge(
|
|
152
|
+
pair("", "").replace('<audio id="a" src="assets/one/', '<audio id="a" src="assets/two/'),
|
|
153
|
+
),
|
|
154
|
+
).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("refuses when only one member is hidden, which would change what is heard", () => {
|
|
158
|
+
expect(merge(pair("", " data-hidden"))).toBeNull();
|
|
159
|
+
expect(merge(pair(" data-hidden", ""))).toBeNull();
|
|
160
|
+
expect(merge(pair(" data-hidden", " data-hidden"))).not.toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("detachAudioInSource on a hidden video", () => {
|
|
165
|
+
it("keeps the detached sound silent", () => {
|
|
166
|
+
const hidden = SOURCE.replace('<video id="talk"', '<video id="talk" data-hidden');
|
|
167
|
+
const result = detachAudioInSource(hidden, { target: video, videoId: "talk", track: 2 });
|
|
168
|
+
expect(attrsOf(result?.html ?? "", "audio#talk-audio")).toHaveProperty("data-hidden");
|
|
169
|
+
expect(attrsOf(result?.html ?? "", "video#talk")).toHaveProperty("data-hidden");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("removeElementInSource", () => {
|
|
174
|
+
it("removes the element and its whole line", () => {
|
|
175
|
+
expect(removeElementInSource('<div>\n <audio id="a"></audio>\n</div>', { id: "a" })).toBe(
|
|
176
|
+
"<div>\n</div>",
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("linkInSource", () => {
|
|
182
|
+
it("mints an id no element or link already uses", () => {
|
|
183
|
+
const src = '<video id="lk-1" data-link="lk-2"></video><audio id="a"></audio>';
|
|
184
|
+
expect(linkInSource(src, [{ id: "a" }])).toContain(
|
|
185
|
+
'<audio id="a" data-link="lk-3" data-sync-origin="lk-3">',
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("skips ids already used as a sync origin", () => {
|
|
190
|
+
const src = '<video id="v" data-sync-origin="lk-1"></video><audio id="a"></audio>';
|
|
191
|
+
expect(linkInSource(src, [{ id: "a" }])).toContain('data-link="lk-2"');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("setLinkInSource", () => {
|
|
196
|
+
it("writes and removes data-link on every target", () => {
|
|
197
|
+
const src = '<video id="v"></video><audio id="a"></audio>';
|
|
198
|
+
const linked = setLinkInSource(src, [{ id: "v" }, { id: "a" }], "lk-1");
|
|
199
|
+
expect(linked).toBe(
|
|
200
|
+
'<video id="v" data-link="lk-1"></video><audio id="a" data-link="lk-1"></audio>',
|
|
201
|
+
);
|
|
202
|
+
expect(setLinkInSource(linked, [{ id: "v" }, { id: "a" }], null)).toBe(src);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("keeps the sync origin when unlinking", () => {
|
|
206
|
+
const linked = linkInSource('<video id="v"></video><audio id="a"></audio>', [
|
|
207
|
+
{ id: "v" },
|
|
208
|
+
{ id: "a" },
|
|
209
|
+
]);
|
|
210
|
+
const unlinked = setLinkInSource(linked, [{ id: "v" }, { id: "a" }], null);
|
|
211
|
+
expect(unlinked).toBe(
|
|
212
|
+
'<video id="v" data-sync-origin="lk-1"></video><audio id="a" data-sync-origin="lk-1"></audio>',
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const el = (id: string, tag: string, extra: Record<string, unknown> = {}) => ({
|
|
218
|
+
id,
|
|
219
|
+
tag,
|
|
220
|
+
src: "assets/talk.mp4",
|
|
221
|
+
start: 2,
|
|
222
|
+
duration: 6,
|
|
223
|
+
track: 0,
|
|
224
|
+
...extra,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("predicates", () => {
|
|
228
|
+
it("detaches only an unmuted video with declared audio", () => {
|
|
229
|
+
expect(canDetachAudio(el("v", "video", { hasAudio: true }))).toBe(true);
|
|
230
|
+
expect(canDetachAudio(el("v", "video", { hasAudio: true, muted: true }))).toBe(false);
|
|
231
|
+
expect(canDetachAudio(el("v", "video"))).toBe(false);
|
|
232
|
+
expect(canDetachAudio(el("a", "audio", { hasAudio: true }))).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("finds the merge pair from either member, linked or identically timed", () => {
|
|
236
|
+
const v = el("v", "video", { muted: true });
|
|
237
|
+
const a = el("a", "audio");
|
|
238
|
+
expect(findMergePair(v, [v, a])).toEqual({ video: v, audio: a });
|
|
239
|
+
expect(findMergePair(a, [v, a])).toEqual({ video: v, audio: a });
|
|
240
|
+
const drifted = el("a", "audio", { start: 3 });
|
|
241
|
+
expect(findMergePair(v, [v, drifted])).toBeNull();
|
|
242
|
+
const linked = [
|
|
243
|
+
el("v", "video", { muted: true, link: "lk-1" }),
|
|
244
|
+
el("a", "audio", { start: 3, link: "lk-1" }),
|
|
245
|
+
];
|
|
246
|
+
expect(findMergePair(linked[0] ?? v, linked)?.audio.id).toBe("a");
|
|
247
|
+
expect(findMergePair(el("v", "video"), [el("v", "video"), a])).toBeNull();
|
|
248
|
+
expect(findMergePair(v, [v, el("a", "audio", { src: "other.mp4" })])).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("links exactly one video and one audio of the same file and timing", () => {
|
|
252
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio")])).toBe(true);
|
|
253
|
+
expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3 })])).toBe(false);
|
|
254
|
+
expect(canLinkPair([el("v", "video"), el("w", "video")])).toBe(false);
|
|
255
|
+
expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio", { link: "x" })])).toBe(
|
|
256
|
+
false,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("predicates compare the whole asset path", () => {
|
|
262
|
+
const one = { src: "assets/one/talk.mp4" };
|
|
263
|
+
const two = { src: "assets/two/talk.mp4" };
|
|
264
|
+
|
|
265
|
+
it("does not pair same-named files in different folders", () => {
|
|
266
|
+
const v = el("v", "video", { muted: true, ...one });
|
|
267
|
+
const a = el("a", "audio", two);
|
|
268
|
+
expect(findMergePair(v, [v, a])).toBeNull();
|
|
269
|
+
expect(canLinkPair([el("v", "video", one), el("a", "audio", two)])).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("resolves each src against its own source file, and links only inside one", () => {
|
|
273
|
+
const v = el("v", "video", { src: "../assets/talk.mp4", sourceFile: "scenes/a.html" });
|
|
274
|
+
const a = el("a", "audio", { src: "./assets/talk.mp4", sourceFile: "index.html" });
|
|
275
|
+
expect(canLinkPair([v, a])).toBe(false);
|
|
276
|
+
expect(
|
|
277
|
+
canLinkPair([v, { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" }]),
|
|
278
|
+
).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("does not take a same-id link from another source file as the merge partner", () => {
|
|
282
|
+
const v = el("v", "video", { muted: true, link: "lk-1", sourceFile: "index.html" });
|
|
283
|
+
const a = el("a", "audio", { start: 3, link: "lk-1", sourceFile: "child.html" });
|
|
284
|
+
expect(findMergePair(v, [v, a])).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("does not link or merge a pair split across inline compositions", () => {
|
|
288
|
+
const v = el("v", "video", { muted: true, compositionScope: "main" });
|
|
289
|
+
const a = el("a", "audio", { compositionScope: "child" });
|
|
290
|
+
expect(canLinkPair([v, a])).toBe(false);
|
|
291
|
+
expect(findMergePair(v, [v, a])).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("does not merge a hidden audio back into a visible video", () => {
|
|
295
|
+
const v = el("v", "video", { muted: true });
|
|
296
|
+
expect(findMergePair(v, [v, el("a", "audio", { hidden: true })])).toBeNull();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("pickDetachedAudioTrack", () => {
|
|
301
|
+
it("uses the first free audio track below the visual tracks", () => {
|
|
302
|
+
const elements = [el("v", "video", { track: 0 }), el("t", "div", { track: 1 })];
|
|
303
|
+
expect(pickDetachedAudioTrack(elements, { start: 2, duration: 6 })).toBe(2);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("reuses an audio track that is free in the clip's window", () => {
|
|
307
|
+
const elements = [
|
|
308
|
+
el("v", "video", { track: 0 }),
|
|
309
|
+
el("m", "audio", { track: 1, start: 0, duration: 2 }),
|
|
310
|
+
];
|
|
311
|
+
expect(pickDetachedAudioTrack(elements, { start: 2, duration: 6 })).toBe(1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("opens a new bottom track when every audio track is busy", () => {
|
|
315
|
+
const elements = [
|
|
316
|
+
el("v", "video", { track: 0 }),
|
|
317
|
+
el("m", "audio", { track: 1, start: 0, duration: 10 }),
|
|
318
|
+
];
|
|
319
|
+
expect(pickDetachedAudioTrack(elements, { start: 2, duration: 6 })).toBe(2);
|
|
320
|
+
});
|
|
321
|
+
});
|