@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.
Files changed (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.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-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /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
- 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";
25
31
  const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
26
32
 
27
- const MOVED_SOUND_ATTRS = [
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
- for (const el of Array.from(doc.querySelectorAll(`[id], [${MEDIA_LINK_ATTR}]`))) {
99
- if (el.id) taken.add(el.id);
100
- const link = el.getAttribute(MEDIA_LINK_ATTR);
101
- 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);
102
115
  }
103
- return firstFreeName("link", taken);
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 insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
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
- const originalSrc = video.getAttribute("src") ?? "";
181
+ let audioInserted = false;
159
182
 
160
183
  const movedSound: Array<[string, string]> = [];
161
- const ops: PatchOperation[] = [...cutoutOps({ isVideo: true, cutoutSrc: input.cutoutSrc })];
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(dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId));
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
- return insertBeforeTarget(source, target, `<audio ${formatAttrs(attrs)}></audio>`);
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
- const landed = keep
235
- ? await commitKeepSoundCutout(ctx, cutoutSrc, label)
236
- : await ctx.commit(ctx.element, cutoutOps({ isVideo, cutoutSrc }), { label });
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
- return keep ? SOUND_KEPT_ON_LINKED_AUDIO_STAGE : "Applied cutout";
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
- return ctx.commit(ctx.element, edit.ops, { label, prepareContent: edit.prepareContent });
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('"', "&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
+ });