@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
|
@@ -23,13 +23,17 @@ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
|
23
23
|
import { buildDomEditPatchTarget, type DomEditSelection } from "./domEditing";
|
|
24
24
|
import { generateId } from "../../utils/generateId";
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
import {
|
|
27
|
+
MEDIA_LINK_ATTR,
|
|
28
|
+
SYNC_ORIGIN_ATTR,
|
|
29
|
+
mintLinkId as mintLinkIdFrom,
|
|
30
|
+
} from "@hyperframes/core/media-link";
|
|
27
31
|
const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
|
|
28
32
|
|
|
29
33
|
const SOUND_NOT_KEPT_MESSAGE =
|
|
30
34
|
"Background removed, but the sound could not be kept on a linked audio track. Undo to restore the original clip.";
|
|
31
35
|
|
|
32
|
-
const MOVED_SOUND_ATTRS = [
|
|
36
|
+
export const MOVED_SOUND_ATTRS = [
|
|
33
37
|
"data-volume",
|
|
34
38
|
HF_AUDIO_FADE_IN_ATTR,
|
|
35
39
|
HF_AUDIO_FADE_OUT_ATTR,
|
|
@@ -38,7 +42,7 @@ const MOVED_SOUND_ATTRS = [
|
|
|
38
42
|
HF_AUDIO_GROUP_ATTR,
|
|
39
43
|
];
|
|
40
44
|
|
|
41
|
-
const COPIED_TIMING_ATTRS = [
|
|
45
|
+
export const COPIED_TIMING_ATTRS = [
|
|
42
46
|
"start",
|
|
43
47
|
"end",
|
|
44
48
|
"duration",
|
|
@@ -91,7 +95,7 @@ export function cutoutOps(input: { isVideo: boolean; cutoutSrc: string }): Patch
|
|
|
91
95
|
return input.isVideo ? [src, htmlAttr("muted", "true"), dataAttr("has-audio", null)] : [src];
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
function firstFreeName(base: string, taken: Set<string>): string {
|
|
98
|
+
export function firstFreeName(base: string, taken: Set<string>): string {
|
|
95
99
|
if (!taken.has(base)) return base;
|
|
96
100
|
let n = 2;
|
|
97
101
|
while (taken.has(`${base}-${n}`)) n += 1;
|
|
@@ -100,12 +104,16 @@ function firstFreeName(base: string, taken: Set<string>): string {
|
|
|
100
104
|
|
|
101
105
|
export function mintLinkId(doc: Document): string {
|
|
102
106
|
const taken = new Set<string>();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
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);
|
|
107
115
|
}
|
|
108
|
-
return
|
|
116
|
+
return mintLinkIdFrom(taken);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
function firstFreeTrackIndex(source: string): number {
|
|
@@ -117,7 +125,7 @@ function firstFreeTrackIndex(source: string): number {
|
|
|
117
125
|
return track;
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
|
|
128
|
+
export function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
|
|
121
129
|
let lanes: HfAutomationLane[];
|
|
122
130
|
try {
|
|
123
131
|
lanes = parseAutomation(raw).lanes;
|
|
@@ -132,11 +140,11 @@ function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes:
|
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
function formatAttrs(attrs: Array<[string, string]>): string {
|
|
143
|
+
export function formatAttrs(attrs: Array<[string, string]>): string {
|
|
136
144
|
return attrs.map(([name, value]) => `${name}="${escapeHtmlAttribute(value)}"`).join(" ");
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
147
|
+
export function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
140
148
|
const tag = findTagByTarget(source, target);
|
|
141
149
|
if (!tag) return "";
|
|
142
150
|
const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
|
|
@@ -145,7 +153,7 @@ function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
|
145
153
|
return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
|
|
156
|
+
export function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
|
|
149
157
|
const match = findTagByTarget(source, target);
|
|
150
158
|
if (!match) return source;
|
|
151
159
|
const lineStart = source.lastIndexOf("\n", match.start) + 1;
|
|
@@ -187,7 +195,10 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
187
195
|
movedSound.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
|
|
188
196
|
ops.push(dataAttr(stripDataPrefix(HF_AUDIO_AUTOMATION_ATTR), videoKeeps));
|
|
189
197
|
}
|
|
190
|
-
ops.push(
|
|
198
|
+
ops.push(
|
|
199
|
+
dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId),
|
|
200
|
+
dataAttr(stripDataPrefix(SYNC_ORIGIN_ATTR), linkId),
|
|
201
|
+
);
|
|
191
202
|
|
|
192
203
|
const prepareContent = (source: string): string => {
|
|
193
204
|
const originalSrc = readAuthoredSrc(source, target);
|
|
@@ -208,9 +219,11 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
208
219
|
...timing,
|
|
209
220
|
["data-track-index", String(firstFreeTrackIndex(source))],
|
|
210
221
|
[MEDIA_LINK_ATTR, linkId],
|
|
222
|
+
[SYNC_ORIGIN_ATTR, linkId],
|
|
211
223
|
...movedSound,
|
|
212
224
|
];
|
|
213
225
|
if (video.hasAttribute("loop")) attrs.push(["loop", ""]);
|
|
226
|
+
if (video.hasAttribute("data-hidden")) attrs.push(["data-hidden", ""]);
|
|
214
227
|
const cut = applyPatchByTarget(source, target, cutoutSrcOp);
|
|
215
228
|
const inserted = insertBeforeTarget(cut, target, `<audio ${formatAttrs(attrs)}></audio>`);
|
|
216
229
|
audioInserted = inserted !== cut;
|
|
@@ -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
|
+
});
|