@hyperframes/studio 0.8.106 → 0.8.108

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 (184) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
  4. package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
  9. package/dist/chunk-AOWVEVEU.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/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +377 -12
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +13445 -11874
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +6 -3
  21. package/src/agentToolsExport.test.ts +8 -0
  22. package/src/captions/keyboard.test.ts +6 -0
  23. package/src/components/EditorShell.tsx +2 -0
  24. package/src/components/StudioRightPanels.tsx +2 -0
  25. package/src/components/TimelineToolbar.test.tsx +27 -0
  26. package/src/components/TimelineToolbar.tsx +2 -5
  27. package/src/components/editor/CropPresetBar.tsx +69 -0
  28. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  29. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  30. package/src/components/editor/DomEditOverlay.tsx +12 -18
  31. package/src/components/editor/PropertyPanel.tsx +1 -14
  32. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  33. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  34. package/src/components/editor/SnapToolbar.tsx +3 -4
  35. package/src/components/editor/carveOutput.ts +54 -0
  36. package/src/components/editor/cropPresetStore.ts +33 -0
  37. package/src/components/editor/cropPresets.test.ts +41 -0
  38. package/src/components/editor/cropPresets.ts +24 -0
  39. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  40. package/src/components/editor/domEditingLayers.test.ts +21 -0
  41. package/src/components/editor/domEditingLayers.ts +2 -0
  42. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  43. package/src/components/editor/fontAssets.test.ts +12 -22
  44. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  45. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  46. package/src/components/editor/manualEdits.test.ts +4 -3
  47. package/src/components/editor/manualEditsDom.ts +28 -5
  48. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  49. package/src/components/editor/manualOffsetDrag.ts +15 -1
  50. package/src/components/editor/mediaAudioEdits.test.ts +271 -0
  51. package/src/components/editor/mediaAudioEdits.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 +290 -87
  55. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  58. package/src/components/editor/propertyPanelTypes.ts +2 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  60. package/src/components/editor/useDomEditNudge.ts +46 -23
  61. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  62. package/src/components/editor/useFxCarve.ts +3 -61
  63. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  64. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  65. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  66. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  67. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  68. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  69. package/src/components/panels/SlideshowPanel.tsx +12 -7
  70. package/src/contexts/DomEditContext.test.tsx +62 -0
  71. package/src/contexts/DomEditContext.tsx +94 -77
  72. package/src/contexts/TimelineEditContext.tsx +1 -0
  73. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  74. package/src/hooks/domEditCommitTypes.ts +10 -0
  75. package/src/hooks/domEditPersistFailure.ts +12 -1
  76. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  77. package/src/hooks/domStyleCommit.ts +2 -8
  78. package/src/hooks/gsapDragCommit.ts +3 -8
  79. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  80. package/src/hooks/gsapEditOutcome.ts +2 -1
  81. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  82. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  83. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  84. package/src/hooks/gsapResizeIntercept.ts +6 -2
  85. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  86. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  87. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  88. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  89. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  90. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  91. package/src/hooks/useAppHotkeys.test.ts +17 -0
  92. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  93. package/src/hooks/useAppHotkeys.ts +3 -3
  94. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  95. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  96. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  97. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  98. package/src/hooks/useDomEditCommits.ts +2 -0
  99. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  100. package/src/hooks/useDomEditPersist.ts +28 -13
  101. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  102. package/src/hooks/useDomEditSession.ts +2 -3
  103. package/src/hooks/useDomEditTextCommits.ts +4 -8
  104. package/src/hooks/useDomGeometryCommits.ts +11 -2
  105. package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
  106. package/src/hooks/useEditHistoryActions.ts +13 -4
  107. package/src/hooks/useElementPicker.test.ts +62 -13
  108. package/src/hooks/useElementPicker.ts +10 -2
  109. package/src/hooks/useFreezeFrame.test.ts +56 -0
  110. package/src/hooks/useFreezeFrame.ts +121 -0
  111. package/src/hooks/useGsapAwareEditing.ts +6 -6
  112. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  113. package/src/hooks/useGsapScriptCommits.ts +5 -4
  114. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  115. package/src/hooks/useInlineTextEdit.ts +9 -7
  116. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  117. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  118. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  119. package/src/hooks/usePersistentEditHistory.ts +1 -1
  120. package/src/hooks/usePreviewPersistence.ts +6 -0
  121. package/src/hooks/useRazorSplit.ts +14 -1
  122. package/src/hooks/useRenderClipContent.test.ts +102 -0
  123. package/src/hooks/useRenderClipContent.ts +30 -23
  124. package/src/hooks/useTimelineEditing.ts +2 -2
  125. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  126. package/src/index.ts +8 -0
  127. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  128. package/src/player/components/ClipBadges.test.tsx +82 -0
  129. package/src/player/components/ClipBadges.tsx +67 -0
  130. package/src/player/components/ClipContextMenu.tsx +22 -0
  131. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  132. package/src/player/components/ClipPeakMarks.tsx +71 -0
  133. package/src/player/components/Timeline.test.ts +0 -21
  134. package/src/player/components/Timeline.tsx +0 -1
  135. package/src/player/components/TimelineClip.tsx +2 -0
  136. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  137. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  138. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  139. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  140. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  141. package/src/player/components/clipMenuDuck.test.ts +117 -0
  142. package/src/player/components/clipMenuDuck.ts +142 -0
  143. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  144. package/src/player/components/clipMenuNormalize.ts +96 -0
  145. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  146. package/src/player/components/clipMenuToolItems.tsx +235 -0
  147. package/src/player/components/clipPeakRuns.test.ts +38 -0
  148. package/src/player/components/clipPeakRuns.ts +59 -0
  149. package/src/player/components/clipToolAttrs.test.ts +161 -0
  150. package/src/player/components/clipToolAttrs.ts +187 -0
  151. package/src/player/components/timelineCallbacks.ts +1 -0
  152. package/src/player/components/timelineLayout.ts +0 -28
  153. package/src/player/components/useClipToolState.ts +92 -0
  154. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  155. package/src/player/lib/playbackShortcuts.ts +3 -7
  156. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  157. package/src/player/lib/timelineElementHelpers.ts +25 -2
  158. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  159. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  160. package/src/timelineChromeExports.test.tsx +7 -0
  161. package/src/utils/audioMeterMath.test.ts +37 -0
  162. package/src/utils/audioMeterMath.ts +9 -3
  163. package/src/utils/exportPeakCeiling.ts +1 -0
  164. package/src/utils/gsapSoftReload.ts +3 -2
  165. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  166. package/src/utils/gsapUndoRestore.test.ts +145 -1
  167. package/src/utils/gsapUndoRestore.ts +70 -21
  168. package/src/utils/previewRelativePath.ts +19 -0
  169. package/src/utils/sourcePatcher.ts +2 -2
  170. package/src/utils/studioFontHelpers.ts +2 -23
  171. package/src/utils/studioHelpers.test.ts +30 -0
  172. package/src/utils/studioHelpers.ts +18 -4
  173. package/src/utils/studioPendingEdits.test.ts +129 -0
  174. package/src/utils/studioPendingEdits.ts +61 -18
  175. package/src/utils/timelineInspector.test.ts +33 -2
  176. package/src/utils/timelineInspector.ts +16 -4
  177. package/src/utils/typingTarget.test.ts +13 -0
  178. package/src/utils/typingTarget.ts +5 -2
  179. package/src/webmcp/useStudioAgentTools.ts +3 -1
  180. package/dist/chunk-46XP7J3B.js.map +0 -1
  181. package/dist/chunk-DI4LCJHL.js +0 -102
  182. package/dist/chunk-DI4LCJHL.js.map +0 -1
  183. package/dist/chunk-ICTZJPQQ.js.map +0 -1
  184. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -1,3 +1,6 @@
1
+ export const gsapRendersTransform = (el: Element) =>
2
+ !!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
3
+
1
4
  /**
2
5
  * Checks whether GSAP actively animates one or more CSS/GSAP properties on
3
6
  * the given element by inspecting all registered `__timelines`.
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { describe, expect, it, vi } from "vitest";
2
3
  import { Window } from "happy-dom";
3
4
  import {
@@ -285,7 +286,7 @@ describe("studio manual edits", () => {
285
286
  expect(set).not.toHaveBeenCalled();
286
287
  expect(tickerTick).not.toHaveBeenCalled();
287
288
 
288
- beginStudioManualEditGesture(card);
289
+ beginStudioManualEditGesture(card, "move");
289
290
  applyStudioPathOffsetDraft(card, { x: 35, y: -6 });
290
291
 
291
292
  expect(readStudioPathOffset(card)).toEqual({ x: 35, y: -6 });
@@ -395,8 +396,8 @@ describe("studio manual edits", () => {
395
396
  const card = document.getElementById("card") as HTMLElement;
396
397
 
397
398
  applyStudioPathOffset(card, { x: 40, y: 24 });
398
- const firstToken = beginStudioManualEditGesture(card);
399
- const secondToken = beginStudioManualEditGesture(card);
399
+ const firstToken = beginStudioManualEditGesture(card, "move");
400
+ const secondToken = beginStudioManualEditGesture(card, "move");
400
401
  endStudioManualEditGesture(card, firstToken);
401
402
 
402
403
  // Gesture still active — offset should remain
@@ -35,13 +35,26 @@ import {
35
35
  import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
36
36
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
37
37
  import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
38
+ import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";
38
39
 
39
40
  /* ── Gesture tracking ─────────────────────────────────────────────── */
40
41
  let studioManualEditGestureId = 0;
41
42
 
42
- export function beginStudioManualEditGesture(element: HTMLElement): string {
43
+ export type StudioGestureDraws = "move" | "resize" | "rotate" | "edit";
44
+ const MOVE_DRAWS = ["translate", STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP];
45
+ const GESTURE_DRAWS: Record<StudioGestureDraws, readonly string[]> = {
46
+ move: MOVE_DRAWS,
47
+ resize: [...MOVE_DRAWS, STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, ...BOX_SIZE_STYLE_PROPS],
48
+ rotate: ["rotate", "transform", "transform-origin", "display", STUDIO_ROTATION_PROP],
49
+ edit: [],
50
+ };
51
+
52
+ export function beginStudioManualEditGesture(
53
+ element: HTMLElement,
54
+ draws: StudioGestureDraws,
55
+ ): string {
43
56
  studioManualEditGestureId += 1;
44
- const token = `gesture-${studioManualEditGestureId}`;
57
+ const token = `gesture-${studioManualEditGestureId}:${draws}`;
45
58
  element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token);
46
59
  return token;
47
60
  }
@@ -78,10 +91,20 @@ export function studioManualEditSavesIn(doc: Document): number {
78
91
  return gestureSaves.get(doc) ?? 0;
79
92
  }
80
93
 
81
- /** A gesture's save in `element`'s document: a reload requested before it shows the file before it. */
82
- export function noteStudioManualEditSave(element: HTMLElement): void {
94
+ /** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
95
+ export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
83
96
  const doc = element.ownerDocument;
84
- gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
97
+ const count = () => void gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
98
+ count();
99
+ const result = save();
100
+ void Promise.resolve(result).then(count, count);
101
+ return result;
102
+ }
103
+
104
+ export function studioGestureDraws(element: Element): readonly string[] | null {
105
+ const token = element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
106
+ if (token === null) return null;
107
+ return GESTURE_DRAWS[token.split(":")[1] as StudioGestureDraws] ?? [];
85
108
  }
86
109
 
87
110
  function isStudioManualEditGestureActive(element: HTMLElement): boolean {
@@ -1,4 +1,5 @@
1
1
  import type { PatchOperation } from "../../utils/sourcePatcher";
2
+ import { gsapRendersTransform } from "./gsapAnimatesProperty";
2
3
  import {
3
4
  STUDIO_OFFSET_X_PROP,
4
5
  STUDIO_OFFSET_Y_PROP,
@@ -127,7 +128,10 @@ const BOX_SIZE_ORIG_ATTRS: ReadonlyArray<[string, string]> = [
127
128
  export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] {
128
129
  const ops: PatchOperation[] = [];
129
130
  collectInlineStyleOps(element, [STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP], ops);
130
- collectInlineStyleOps(element, BOX_SIZE_STYLE_PROPS, ops);
131
+ const withoutGsapScaleMask = gsapRendersTransform(element)
132
+ ? BOX_SIZE_STYLE_PROPS.filter((prop) => prop !== "scale")
133
+ : BOX_SIZE_STYLE_PROPS;
134
+ collectInlineStyleOps(element, withoutGsapScaleMask, ops);
131
135
  ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" });
132
136
  collectAttributeOps(
133
137
  element,
@@ -13,6 +13,7 @@ import {
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
14
  import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
15
15
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
+ import type { StudioEditRevert } from "../../utils/studioPendingEdits";
16
17
 
17
18
  interface OffsetDragGsap {
18
19
  set: (el: Element, vars: Record<string, number | string>) => void;
@@ -356,7 +357,10 @@ export function createManualOffsetDragMember(input: {
356
357
  }
357
358
 
358
359
  const initialPathOffset = captureStudioPathOffset(input.element);
359
- const gestureToken = beginStudioManualEditGesture(input.element);
360
+ const gestureToken = beginStudioManualEditGesture(
361
+ input.element,
362
+ input.gesture === "resize" ? "resize" : "move",
363
+ );
360
364
  const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
361
365
  scaleX: input.rect.editScaleX,
362
366
  scaleY: input.rect.editScaleY,
@@ -507,6 +511,16 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
507
511
  endStudioManualEditGesture(member.element, member.gestureToken);
508
512
  }
509
513
 
514
+ /** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
515
+ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
516
+ if (!members.every((member) => member.plainTranslate)) return null;
517
+ return () => {
518
+ const shown = members.map((member) => captureStudioPathOffset(member.element));
519
+ for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
520
+ return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
521
+ };
522
+ }
523
+
510
524
  /** Roll back a FAILED drag to the exact gesture-start state. */
511
525
  export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
512
526
  for (const member of members) {
@@ -0,0 +1,271 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { applyPatchByTarget } from "../../utils/sourcePatcher";
4
+ import {
5
+ buildKeepSoundCutoutEdit,
6
+ cutoutOps,
7
+ hasAudioToggleOps,
8
+ mintLinkId,
9
+ mutedToggleOps,
10
+ } from "./mediaAudioEdits";
11
+
12
+ const attr = (property: string, value: string | null) => ({
13
+ type: "attribute" as const,
14
+ property,
15
+ value,
16
+ });
17
+ const html = (property: string, value: string | null) => ({
18
+ type: "html-attribute" as const,
19
+ property,
20
+ value,
21
+ });
22
+
23
+ describe("mutedToggleOps", () => {
24
+ it("mutes a video and removes has-audio instead of writing an empty marker", () => {
25
+ expect(mutedToggleOps({ isVideo: true, nextMuted: true, probedHasAudio: null })).toEqual([
26
+ html("muted", "true"),
27
+ attr("has-audio", null),
28
+ ]);
29
+ });
30
+
31
+ it("unmutes a video the probe hears as audible", () => {
32
+ expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: true })).toEqual([
33
+ html("muted", null),
34
+ attr("has-audio", "true"),
35
+ ]);
36
+ });
37
+
38
+ it("unmutes a silent-probe video with an explicit has-audio=false", () => {
39
+ expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: false })).toEqual([
40
+ html("muted", null),
41
+ attr("has-audio", "false"),
42
+ ]);
43
+ });
44
+
45
+ it("leaves has-audio absent when the probe fails", () => {
46
+ expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: null })).toEqual([
47
+ html("muted", null),
48
+ attr("has-audio", null),
49
+ ]);
50
+ });
51
+
52
+ it("toggles only muted on an audio element", () => {
53
+ expect(mutedToggleOps({ isVideo: false, nextMuted: true, probedHasAudio: null })).toEqual([
54
+ html("muted", "true"),
55
+ ]);
56
+ expect(mutedToggleOps({ isVideo: false, nextMuted: false, probedHasAudio: null })).toEqual([
57
+ html("muted", null),
58
+ ]);
59
+ });
60
+ });
61
+
62
+ describe("hasAudioToggleOps", () => {
63
+ it("turns sound on by stamping has-audio and clearing muted", () => {
64
+ expect(hasAudioToggleOps(true)).toEqual([attr("has-audio", "true"), html("muted", null)]);
65
+ });
66
+
67
+ it("turns sound off by removing has-audio rather than writing an empty value", () => {
68
+ expect(hasAudioToggleOps(false)).toEqual([attr("has-audio", null), html("muted", "true")]);
69
+ });
70
+ });
71
+
72
+ describe("cutoutOps", () => {
73
+ it("swaps the src of an image and nothing else", () => {
74
+ expect(cutoutOps({ isVideo: false, cutoutSrc: "assets/cut.png" })).toEqual([
75
+ html("src", "assets/cut.png"),
76
+ ]);
77
+ });
78
+
79
+ it("swaps a silent video's src and keeps it muted without a has-audio marker", () => {
80
+ expect(cutoutOps({ isVideo: true, cutoutSrc: "assets/cut.webm" })).toEqual([
81
+ html("src", "assets/cut.webm"),
82
+ html("muted", "true"),
83
+ attr("has-audio", null),
84
+ ]);
85
+ });
86
+ });
87
+
88
+ describe("mintLinkId", () => {
89
+ it("dedupes against element ids and existing links", () => {
90
+ const doc = document.implementation.createHTMLDocument("t");
91
+ expect(mintLinkId(doc)).toBe("link");
92
+ doc.body.innerHTML = '<div id="link"></div><video data-link="link-2"></video>';
93
+ expect(mintLinkId(doc)).toBe("link-3");
94
+ });
95
+ });
96
+
97
+ function makeVideo(attrs: Record<string, string>): HTMLVideoElement {
98
+ const doc = document.implementation.createHTMLDocument("t");
99
+ const video = doc.createElement("video");
100
+ for (const [name, value] of Object.entries(attrs)) video.setAttribute(name, value);
101
+ doc.body.append(video);
102
+ return video;
103
+ }
104
+
105
+ function applyEdit(source: string, edit: ReturnType<typeof buildKeepSoundCutoutEdit>): string {
106
+ const target = { id: "clip" };
107
+ const patched = edit.ops.reduce((acc, op) => applyPatchByTarget(acc, target, op), source);
108
+ return edit.prepareContent(patched);
109
+ }
110
+
111
+ function parse(source: string): Document {
112
+ return new DOMParser().parseFromString(source, "text/html");
113
+ }
114
+
115
+ describe("buildKeepSoundCutoutEdit", () => {
116
+ const automation = JSON.stringify({
117
+ version: 1,
118
+ lanes: [
119
+ { target: "volume", points: [{ t: 0, v: 1 }] },
120
+ { target: "rate", points: [{ t: 0, v: 2 }] },
121
+ ],
122
+ });
123
+
124
+ const source = [
125
+ '<div data-composition-id="main" data-duration="10">',
126
+ ' <video id="clip" class="clip" src="assets/talk.mp4" data-start="1" data-duration="4" data-media-start="2" data-track-index="0" data-has-audio="true" data-volume="0.5" data-fade-in="0.3" data-audio-group="voiceover"></video>',
127
+ ' <audio id="music" class="clip" src="assets/bgm.mp3" data-start="0" data-duration="10" data-track-index="1"></audio>',
128
+ "</div>",
129
+ ].join("\n");
130
+
131
+ const liveVideo = () =>
132
+ makeVideo({
133
+ id: "clip",
134
+ src: "assets/talk.mp4",
135
+ "data-has-audio": "true",
136
+ "data-volume": "0.5",
137
+ "data-fade-in": "0.3",
138
+ "data-audio-group": "voiceover",
139
+ "data-automation": automation,
140
+ });
141
+
142
+ it("mutes the video onto the cutout and moves its sound to a linked sibling audio", () => {
143
+ const edit = buildKeepSoundCutoutEdit({
144
+ video: liveVideo(),
145
+ videoId: "clip",
146
+ target: { id: "clip" },
147
+ cutoutSrc: "assets/talk-cutout.webm",
148
+ });
149
+ const doc = parse(applyEdit(source, edit));
150
+ const video = doc.getElementById("clip");
151
+ const audio = doc.getElementById("clip-audio");
152
+ if (!video || !audio) throw new Error("expected both clips");
153
+
154
+ expect(video.getAttribute("src")).toBe("assets/talk-cutout.webm");
155
+ expect(video.hasAttribute("muted")).toBe(true);
156
+ expect(video.hasAttribute("data-has-audio")).toBe(false);
157
+ expect(video.hasAttribute("data-volume")).toBe(false);
158
+ expect(video.hasAttribute("data-fade-in")).toBe(false);
159
+ expect(video.hasAttribute("data-audio-group")).toBe(false);
160
+ expect(JSON.parse(video.getAttribute("data-automation") ?? "").lanes).toEqual([
161
+ { target: "rate", points: [{ t: 0, v: 2 }] },
162
+ ]);
163
+
164
+ expect(audio.tagName).toBe("AUDIO");
165
+ expect(audio.getAttribute("src")).toBe("assets/talk.mp4");
166
+ expect(audio.getAttribute("data-start")).toBe("1");
167
+ expect(audio.getAttribute("data-duration")).toBe("4");
168
+ expect(audio.getAttribute("data-media-start")).toBe("2");
169
+ expect(audio.hasAttribute("data-playback-rate")).toBe(false);
170
+ expect(audio.getAttribute("data-track-index")).toBe("2");
171
+ expect(audio.getAttribute("data-volume")).toBe("0.5");
172
+ expect(audio.getAttribute("data-fade-in")).toBe("0.3");
173
+ expect(audio.getAttribute("data-audio-group")).toBe("voiceover");
174
+ expect(JSON.parse(audio.getAttribute("data-automation") ?? "").lanes).toEqual([
175
+ { target: "volume", points: [{ t: 0, v: 1 }] },
176
+ { target: "rate", points: [{ t: 0, v: 2 }] },
177
+ ]);
178
+ expect(audio.getAttribute("data-link")).toBe(video.getAttribute("data-link"));
179
+ expect(video.getAttribute("data-link")).toBe("link");
180
+ expect(audio.parentElement).toBe(video.parentElement);
181
+ });
182
+
183
+ it("drops the video's automation attribute when it held no rate lane", () => {
184
+ const video = makeVideo({
185
+ id: "clip",
186
+ src: "assets/talk.mp4",
187
+ "data-automation": JSON.stringify({
188
+ version: 1,
189
+ lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
190
+ }),
191
+ });
192
+ const edit = buildKeepSoundCutoutEdit({
193
+ video,
194
+ videoId: "clip",
195
+ target: { id: "clip" },
196
+ cutoutSrc: "assets/cut.webm",
197
+ });
198
+ expect(edit.ops).toContainEqual(attr("automation", null));
199
+ });
200
+
201
+ it("suffixes the audio id when <video-id>-audio is taken", () => {
202
+ const edit = buildKeepSoundCutoutEdit({
203
+ video: liveVideo(),
204
+ videoId: "clip",
205
+ target: { id: "clip" },
206
+ cutoutSrc: "assets/cut.webm",
207
+ });
208
+ const taken = source.replace('id="music"', 'id="clip-audio"');
209
+ const doc = parse(applyEdit(taken, edit));
210
+ expect(doc.getElementById("clip-audio-2")?.getAttribute("src")).toBe("assets/talk.mp4");
211
+ });
212
+
213
+ it("reads the authored src from the source, not the live element", () => {
214
+ const sub = source.replace('src="assets/talk.mp4"', 'src="../assets/a.mp4"');
215
+ const edit = buildKeepSoundCutoutEdit({
216
+ video: liveVideo(),
217
+ videoId: "clip",
218
+ target: { id: "clip" },
219
+ cutoutSrc: "assets/cut.webm",
220
+ });
221
+ const doc = parse(applyEdit(sub, edit));
222
+ expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("../assets/a.mp4");
223
+ expect(doc.getElementById("clip")?.getAttribute("src")).toBe("assets/cut.webm");
224
+ });
225
+
226
+ it("takes the first <source> child when the video has no src attribute", () => {
227
+ const withSource = source.replace(
228
+ /<video id="clip"([^>]*?) src="assets\/talk.mp4"([^>]*)><\/video>/,
229
+ '<video id="clip"$1$2><source src="assets/s.mp4" type="video/mp4"></video>',
230
+ );
231
+ const edit = buildKeepSoundCutoutEdit({
232
+ video: liveVideo(),
233
+ videoId: "clip",
234
+ target: { id: "clip" },
235
+ cutoutSrc: "assets/cut.webm",
236
+ });
237
+ const doc = parse(applyEdit(withSource, edit));
238
+ expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("assets/s.mp4");
239
+ });
240
+
241
+ it("reports when the audio insert did not land", () => {
242
+ const edit = buildKeepSoundCutoutEdit({
243
+ video: liveVideo(),
244
+ videoId: "clip",
245
+ target: { id: "missing" },
246
+ cutoutSrc: "assets/cut.webm",
247
+ });
248
+ edit.prepareContent(source);
249
+ expect(edit.audioInserted()).toBe(false);
250
+ const ok = buildKeepSoundCutoutEdit({
251
+ video: liveVideo(),
252
+ videoId: "clip",
253
+ target: { id: "clip" },
254
+ cutoutSrc: "assets/cut.webm",
255
+ });
256
+ ok.prepareContent(source);
257
+ expect(ok.audioInserted()).toBe(true);
258
+ });
259
+
260
+ it("copies loop onto the linked audio", () => {
261
+ const video = makeVideo({ id: "clip", src: "assets/talk.mp4", loop: "" });
262
+ const edit = buildKeepSoundCutoutEdit({
263
+ video,
264
+ videoId: "clip",
265
+ target: { id: "clip" },
266
+ cutoutSrc: "assets/cut.webm",
267
+ });
268
+ const doc = parse(applyEdit(source, edit));
269
+ expect(doc.getElementById("clip-audio")?.hasAttribute("loop")).toBe(true);
270
+ });
271
+ });