@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.
Files changed (118) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
  5. package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +44 -4
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +7021 -5669
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  24. package/src/components/editor/InlineTextCaret.tsx +211 -0
  25. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  26. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  27. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  28. package/src/components/editor/domEditInlineText.ts +6 -4
  29. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  30. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  31. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  32. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  33. package/src/components/editor/mediaAudioEdits.ts +27 -14
  34. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  35. package/src/components/editor/mediaLinkEdits.ts +282 -0
  36. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  37. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  38. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  39. package/src/components/editor/useVolumeAutomation.ts +29 -7
  40. package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
  41. package/src/components/ui/Menu.test.tsx +3 -22
  42. package/src/components/ui/Menu.tsx +2 -2
  43. package/src/components/ui/Popover.tsx +2 -2
  44. package/src/components/ui/Select.tsx +2 -2
  45. package/src/components/ui/Tooltip.tsx +2 -2
  46. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  47. package/src/components/ui/valueControls.test.tsx +2 -21
  48. package/src/contexts/TimelineEditContext.tsx +3 -0
  49. package/src/hooks/appHotkeysDispatch.ts +2 -1
  50. package/src/hooks/linkShortcuts.test.ts +70 -0
  51. package/src/hooks/linkShortcuts.ts +41 -0
  52. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  53. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  54. package/src/hooks/timelineMoveAdapter.ts +13 -0
  55. package/src/hooks/timelineTimingSync.test.ts +59 -0
  56. package/src/hooks/timelineTimingSync.ts +22 -3
  57. package/src/hooks/useAppHotkeys.ts +9 -0
  58. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  59. package/src/hooks/useGsapScriptCommits.ts +23 -3
  60. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  61. package/src/hooks/usePreviewPersistence.ts +15 -0
  62. package/src/hooks/useRazorSplit.test.tsx +48 -0
  63. package/src/hooks/useRazorSplit.ts +12 -1
  64. package/src/hooks/useRenderClipContent.test.ts +6 -4
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  67. package/src/hooks/useTimelineEditGuard.ts +54 -0
  68. package/src/hooks/useTimelineEditing.ts +36 -55
  69. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  70. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  71. package/src/index.ts +1 -0
  72. package/src/player/components/AudioWaveform.test.tsx +30 -3
  73. package/src/player/components/AudioWaveform.tsx +6 -7
  74. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  75. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  76. package/src/player/components/ClipContextMenu.tsx +115 -90
  77. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  78. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  79. package/src/player/components/TimelineClip.test.tsx +17 -0
  80. package/src/player/components/TimelineClip.tsx +6 -0
  81. package/src/player/components/audioClipLink.test.ts +228 -31
  82. package/src/player/components/audioClipLink.ts +72 -25
  83. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  84. package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
  85. package/src/player/components/clipMenuAudioItems.tsx +31 -23
  86. package/src/player/components/clipMenuDuck.ts +5 -0
  87. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  88. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  89. package/src/player/components/clipMenuToolItems.tsx +1 -3
  90. package/src/player/components/clipSync.test.ts +67 -0
  91. package/src/player/components/clipSync.ts +80 -0
  92. package/src/player/components/linkLabelColor.test.ts +20 -0
  93. package/src/player/components/linkLabelColor.ts +24 -0
  94. package/src/player/components/timelineCallbacks.ts +11 -0
  95. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  96. package/src/player/components/timelineClipDragTypes.ts +2 -0
  97. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  98. package/src/player/components/timelineLinkSelection.ts +23 -0
  99. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  100. package/src/player/components/useTimelineClipDrag.ts +21 -0
  101. package/src/player/components/useTimelineProviderState.tsx +3 -0
  102. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  103. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  104. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  105. package/src/player/lib/timelineDOM.test.ts +31 -0
  106. package/src/player/lib/timelineDOM.ts +13 -0
  107. package/src/player/store/timelineElement.ts +12 -0
  108. package/src/styles/components.css +27 -11
  109. package/src/styles/theme.css +0 -1
  110. package/src/utils/gsapSoftReload.test.ts +238 -2
  111. package/src/utils/gsapSoftReload.ts +89 -74
  112. package/src/utils/gsapUndoRestore.test.ts +4 -3
  113. package/src/utils/gsapUndoRestore.ts +15 -0
  114. package/src/utils/linkedClipPreferences.ts +34 -0
  115. package/src/utils/softReloadTargets.ts +128 -0
  116. package/src/utils/studioUiPreferences.ts +8 -0
  117. package/dist/chunk-AOWVEVEU.js.map +0 -1
  118. 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
- const MEDIA_LINK_ATTR = "data-link";
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
- for (const el of Array.from(doc.querySelectorAll(`[id], [${MEDIA_LINK_ATTR}]`))) {
104
- if (el.id) taken.add(el.id);
105
- const link = el.getAttribute(MEDIA_LINK_ATTR);
106
- if (link) taken.add(link);
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 firstFreeName("link", taken);
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(dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId));
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('"', "&quot;");
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
+ });