@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
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CROP_ASPECT_PRESETS, centredCropInsets } from "./cropPresets";
3
+
4
+ describe("centredCropInsets", () => {
5
+ it("trims the sides of a wide frame to 9:16, centred", () => {
6
+ const insets = centredCropInsets(1920, 1080, 9 / 16);
7
+ expect(insets.top).toBe(0);
8
+ expect(insets.bottom).toBe(0);
9
+ expect(insets.left).toBeCloseTo((1920 - 607.5) / 2, 6);
10
+ expect(insets.right).toBeCloseTo(insets.left, 6);
11
+ });
12
+
13
+ it("trims top and bottom of a tall frame to 16:9", () => {
14
+ const insets = centredCropInsets(1080, 1920, 16 / 9);
15
+ expect(insets.left).toBe(0);
16
+ expect(insets.right).toBe(0);
17
+ expect(insets.top).toBeCloseTo((1920 - 607.5) / 2, 6);
18
+ expect(insets.bottom).toBeCloseTo(insets.top, 6);
19
+ });
20
+
21
+ it("leaves the visible box in the requested ratio", () => {
22
+ for (const [w, h] of [
23
+ [1920, 1080],
24
+ [1080, 1920],
25
+ [800, 800],
26
+ ]) {
27
+ for (const { ratio } of CROP_ASPECT_PRESETS) {
28
+ const i = centredCropInsets(w, h, ratio);
29
+ expect((w - i.left - i.right) / (h - i.top - i.bottom)).toBeCloseTo(ratio, 6);
30
+ }
31
+ }
32
+ });
33
+
34
+ it("is no crop when the frame already has the ratio", () => {
35
+ expect(centredCropInsets(1600, 900, 16 / 9)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
36
+ });
37
+
38
+ it("offers the four spec ratios", () => {
39
+ expect(CROP_ASPECT_PRESETS.map((p) => p.label)).toEqual(["16:9", "9:16", "1:1", "4:5"]);
40
+ });
41
+ });
@@ -0,0 +1,24 @@
1
+ import type { ClipPathInsetSides } from "./clipPathHelpers";
2
+
3
+ export const CROP_ASPECT_PRESETS = [
4
+ { label: "16:9", ratio: 16 / 9 },
5
+ { label: "9:16", ratio: 9 / 16 },
6
+ { label: "1:1", ratio: 1 },
7
+ { label: "4:5", ratio: 4 / 5 },
8
+ ] as const;
9
+
10
+ /** Insets (element CSS px) that leave the largest centred box of `ratio` (width / height). */
11
+ export function centredCropInsets(
12
+ width: number,
13
+ height: number,
14
+ ratio: number,
15
+ ): ClipPathInsetSides {
16
+ const none = { top: 0, right: 0, bottom: 0, left: 0 };
17
+ if (width <= 0 || height <= 0 || ratio <= 0) return none;
18
+ if (width / height > ratio) {
19
+ const side = (width - height * ratio) / 2;
20
+ return side > 1e-9 ? { ...none, left: side, right: side } : none;
21
+ }
22
+ const band = (height - width / ratio) / 2;
23
+ return band > 1e-9 ? { ...none, top: band, bottom: band } : none;
24
+ }
@@ -54,18 +54,20 @@ export function canEditElementTextInline(element: HTMLElement | null): boolean {
54
54
  }
55
55
 
56
56
  function hasOnlyFormattingChildren(element: HTMLElement): boolean {
57
- const HTMLElementClass = element.ownerDocument.defaultView?.HTMLElement;
58
- if (!HTMLElementClass) return false;
59
57
  for (const child of Array.from(element.children)) {
60
58
  if (!isRichTextFormattingTag(child.tagName)) return false;
61
- if (!(child instanceof HTMLElementClass)) return false;
59
+ if (!isHtmlFromAnyWindow(child)) return false;
62
60
  // Formatting nests, and a structural child hidden inside a span is still
63
61
  // structural.
64
- if (!hasOnlyFormattingChildren(child)) return false;
62
+ if (!hasOnlyFormattingChildren(child as HTMLElement)) return false;
65
63
  }
66
64
  return true;
67
65
  }
68
66
 
67
+ function isHtmlFromAnyWindow(node: Element): boolean {
68
+ return node.namespaceURI === "http://www.w3.org/1999/xhtml";
69
+ }
70
+
69
71
  /** Where and when a press landed, for recognising the next one as a pair. */
70
72
  export interface PressMark {
71
73
  x: number;
@@ -30,6 +30,13 @@ describe("canEditElementTextInline", () => {
30
30
  expect(canEditElementTextInline(element)).toBe(true);
31
31
  });
32
32
 
33
+ it("opens styled copy whose formatting comes from another window", () => {
34
+ const element = mount("<h1>a <strong>bold</strong></h1>");
35
+ // happy-dom shares classes across windows; drop this window's HTMLElement as a foreign realm would.
36
+ Object.setPrototypeOf(element.firstElementChild, Element.prototype);
37
+ expect(canEditElementTextInline(element)).toBe(true);
38
+ });
39
+
33
40
  it("keeps out an element with a structural child, which the panel edits field by field", () => {
34
41
  expect(canEditElementTextInline(mount("<div><h1>a</h1><p>b</p></div>"))).toBe(false);
35
42
  });
@@ -7,6 +7,7 @@ import {
7
7
  buildTextFieldChildLocator,
8
8
  readHfId,
9
9
  liveLayerElement,
10
+ domEditSelectionToFacts,
10
11
  } from "./domEditingLayers";
11
12
  import type { DomEditTextField } from "./domEditingTypes";
12
13
 
@@ -332,3 +333,23 @@ describe("liveLayerElement", () => {
332
333
  ).toBe("strip");
333
334
  });
334
335
  });
336
+
337
+ describe("domEditSelectionToFacts hasAudio", () => {
338
+ async function factsFor(html: string) {
339
+ document.body.innerHTML = html;
340
+ const node = document.body.firstElementChild;
341
+ if (!(node instanceof HTMLElement)) throw new Error("expected element");
342
+ const selection = await resolveDomEditSelection(node, opts);
343
+ document.body.innerHTML = "";
344
+ if (!selection) throw new Error("expected selection");
345
+ return domEditSelectionToFacts(selection);
346
+ }
347
+
348
+ it("is true for an unmuted video, false for muted or data-has-audio=false", async () => {
349
+ expect((await factsFor(`<video id="v1"></video>`)).hasAudio).toBe(true);
350
+ expect((await factsFor(`<video id="v2" data-has-audio="true"></video>`)).hasAudio).toBe(true);
351
+ expect((await factsFor(`<video id="v3" muted></video>`)).hasAudio).toBe(false);
352
+ expect((await factsFor(`<video id="v4" data-has-audio="false"></video>`)).hasAudio).toBe(false);
353
+ expect((await factsFor(`<div id="d1"></div>`)).hasAudio).toBe(false);
354
+ });
355
+ });
@@ -1,4 +1,5 @@
1
1
  import { probeSourceElement } from "./probeSourceElement";
2
+ import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
2
3
  import type { PatchOperation } from "../../utils/sourcePatcher";
3
4
  import {
4
5
  resolveEditingAffordances,
@@ -248,6 +249,7 @@ export function domEditSelectionToFacts(
248
249
  hasEditableText: selection.textFields.length > 0,
249
250
  hasTimingStart: selection.dataAttributes.start != null,
250
251
  animationCount,
252
+ hasAudio: isAudibleVideoNode(selection.element),
251
253
  };
252
254
  }
253
255
 
@@ -0,0 +1,71 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import type { DomEditSelection } from "./domEditing";
4
+ import type { GestureState } from "./domEditOverlayGestures";
5
+ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
+ import {
7
+ hasStudioPendingEdits,
8
+ revertNewestStudioPendingEdit,
9
+ } from "../../utils/studioPendingEdits";
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ const ref = <T>(current: T) => ({ current });
16
+ const pointer = (x: number, y: number) => ({
17
+ clientX: x,
18
+ clientY: y,
19
+ pointerId: 1,
20
+ button: 0,
21
+ preventDefault() {},
22
+ stopPropagation() {},
23
+ currentTarget: { setPointerCapture() {}, releasePointerCapture() {} },
24
+ });
25
+
26
+ /** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
27
+ function dragWithSaveRunning(save: Promise<void>) {
28
+ const element = document.createElement("div");
29
+ element.style.setProperty("translate", "40px 30px");
30
+ document.body.append(element);
31
+ const selection = { element, capabilities: { canApplyManualOffset: true } };
32
+ const handlers = createDomEditOverlayGestureHandlers({
33
+ selectionRef: ref(selection as unknown as DomEditSelection),
34
+ overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
35
+ boxRef: ref(document.createElement("div")),
36
+ overlayRef: ref(null),
37
+ iframeRef: ref(null),
38
+ gestureRef: ref<GestureState | null>(null),
39
+ rafPausedRef: ref(false),
40
+ onManualDragStartRef: ref(vi.fn()),
41
+ onBlockedMoveRef: ref(vi.fn()),
42
+ onPathOffsetCommitRef: ref(vi.fn(() => save)),
43
+ snapGuidesRef: ref(null),
44
+ groupGestureRef: ref(null),
45
+ blockedMoveRef: ref(null),
46
+ setOverlayRect: vi.fn(),
47
+ suppressNextBoxClickRef: ref(false),
48
+ hoverSelectionRef: ref(null),
49
+ onCanvasMouseDown: vi.fn(),
50
+ } as never);
51
+ expect(handlers.startGesture("drag", pointer(10, 10) as never)).toBe(true);
52
+ handlers.onPointerUp(pointer(110, 70) as never);
53
+ return element;
54
+ }
55
+
56
+ it("a drag whose save is still running can be painted back at once, and shown again", async () => {
57
+ let saved!: () => void;
58
+ const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
59
+ const moved = element.style.getPropertyValue("translate");
60
+ expect(moved).not.toBe("40px 30px");
61
+
62
+ const showAgain = revertNewestStudioPendingEdit();
63
+ expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
64
+ expect(revertNewestStudioPendingEdit()).toBeNull();
65
+ showAgain!();
66
+ expect(element.style.getPropertyValue("translate")).toBe(moved);
67
+
68
+ saved();
69
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
70
+ expect(revertNewestStudioPendingEdit()).toBeNull();
71
+ });
@@ -0,0 +1,164 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createHappyDomRootHarness } from "../../player/components/testRootHarness";
5
+ import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
6
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
7
+ import type { DomEditSelection } from "./domEditingTypes";
8
+ import {
9
+ fanOutAttributeInSource,
10
+ linkedPartnerTargets,
11
+ useLinkedSpeedCommit,
12
+ withLinkedPlaybackRate,
13
+ type LinkedSpeedCommit,
14
+ } from "./linkedSpeedEdits";
15
+ import { useVolumeAutomation } from "./useVolumeAutomation";
16
+ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
17
+
18
+ const clip = (id: string, tag: string, link?: string): TimelineElement => ({
19
+ id,
20
+ domId: id,
21
+ tag,
22
+ start: 0,
23
+ duration: 4,
24
+ track: 0,
25
+ ...(link ? { link } : {}),
26
+ });
27
+
28
+ const elements = [
29
+ clip("talk", "video", "lk-1"),
30
+ clip("talk-audio", "audio", "lk-1"),
31
+ clip("music", "audio"),
32
+ ];
33
+
34
+ const source = `<div data-composition-id="main">
35
+ <video id="talk" data-start="0" data-duration="4" data-link="lk-1" muted></video>
36
+ <audio id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="{&quot;version&quot;:1,&quot;lanes&quot;:[]}"></audio>
37
+ <audio id="music" data-start="0" data-duration="4"></audio>
38
+ </div>`;
39
+
40
+ function selection(id: string, dataAttributes: Record<string, string> = {}): DomEditSelection {
41
+ const element = document.createElement("video");
42
+ element.id = id;
43
+ return {
44
+ id,
45
+ element,
46
+ label: id,
47
+ tagName: "video",
48
+ sourceFile: "index.html",
49
+ compositionPath: "index.html",
50
+ isCompositionHost: false,
51
+ isInsideLockedComposition: false,
52
+ boundingBox: { x: 0, y: 0, width: 0, height: 0 },
53
+ textContent: null,
54
+ dataAttributes: { start: "0", duration: "4", ...dataAttributes },
55
+ inlineStyles: {},
56
+ computedStyles: {},
57
+ textFields: [],
58
+ capabilities: {
59
+ canSelect: true,
60
+ canEditStyles: true,
61
+ canCrop: true,
62
+ canMove: true,
63
+ canResize: true,
64
+ canApplyManualOffset: true,
65
+ canApplyManualSize: true,
66
+ canApplyManualRotation: true,
67
+ },
68
+ };
69
+ }
70
+
71
+ describe("linkedPartnerTargets", () => {
72
+ it("returns the other data-link members and nothing for an unlinked clip", () => {
73
+ expect(linkedPartnerTargets({ id: "talk" }, elements)).toEqual([{ id: "talk-audio" }]);
74
+ expect(linkedPartnerTargets({ id: "music" }, elements)).toEqual([]);
75
+ });
76
+
77
+ it("reaches no partner with Linked Selection off", () => {
78
+ useLinkedClipPreferences.getState().setLinkedSelection(false);
79
+ try {
80
+ expect(linkedPartnerTargets({ id: "talk" }, elements)).toEqual([]);
81
+ } finally {
82
+ useLinkedClipPreferences.getState().setLinkedSelection(true);
83
+ }
84
+ });
85
+ });
86
+
87
+ describe("fanOutAttributeInSource", () => {
88
+ it("writes each partner from its own current value", () => {
89
+ const seen: Array<string | undefined> = [];
90
+ const next = fanOutAttributeInSource(
91
+ source,
92
+ [{ id: "talk-audio" }],
93
+ "data-automation",
94
+ (raw) => {
95
+ seen.push(raw);
96
+ return "rate-lane";
97
+ },
98
+ );
99
+ expect(seen).toEqual(['{"version":1,"lanes":[]}']);
100
+ expect(next).toContain(
101
+ 'id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="rate-lane"',
102
+ );
103
+ expect(next).not.toContain('id="music" data-start="0" data-duration="4" data-automation');
104
+ });
105
+ });
106
+
107
+ const harness = createHappyDomRootHarness();
108
+
109
+ function renderLinked(sel: DomEditSelection, commit: CommitDomAttributeBatch) {
110
+ let linked: LinkedSpeedCommit | null = null;
111
+ let rate: ReturnType<typeof useVolumeAutomation>["rate"] | null = null;
112
+ function Probe() {
113
+ linked = useLinkedSpeedCommit(sel, commit);
114
+ rate = useVolumeAutomation(sel, 0, vi.fn(), linked).rate;
115
+ return null;
116
+ }
117
+ const host = document.createElement("div");
118
+ document.body.appendChild(host);
119
+ act(() => harness.mount(host).render(<Probe />));
120
+ return { linked: () => linked, rate: () => rate };
121
+ }
122
+
123
+ describe("linked speed edits", () => {
124
+ it("fans a playback-rate edit out to the partner in one batch commit", async () => {
125
+ usePlayerStore.getState().setElements(elements);
126
+ const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
127
+ const onSetAttribute = vi.fn();
128
+ const probe = renderLinked(selection("talk"), commit);
129
+ await withLinkedPlaybackRate(onSetAttribute, probe.linked())("playback-rate", "2");
130
+ expect(onSetAttribute).not.toHaveBeenCalled();
131
+ expect(commit).toHaveBeenCalledTimes(1);
132
+ const [, ops, options] = commit.mock.calls[0] ?? [];
133
+ expect(ops).toEqual([{ type: "attribute", property: "playback-rate", value: "2" }]);
134
+ expect(options?.prepareContent?.(source)).toContain(
135
+ 'id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="{&quot;version&quot;:1,&quot;lanes&quot;:[]}" data-playback-rate="2"',
136
+ );
137
+ });
138
+
139
+ it("fans a ramp preset out as one commit carrying each member's rate lane", () => {
140
+ usePlayerStore.getState().setElements(elements);
141
+ const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
142
+ const probe = renderLinked(selection("talk"), commit);
143
+ act(() => probe.rate()?.onApplyPreset("ramp-in"));
144
+ expect(commit).toHaveBeenCalledTimes(1);
145
+ const [, ops, options] = commit.mock.calls[0] ?? [];
146
+ expect(ops?.[0]?.property).toBe("automation");
147
+ expect(String(ops?.[0]?.value)).toContain('"target":"rate"');
148
+ const partner = /id="talk-audio"[^>]*data-automation="([^"]*)"/.exec(
149
+ options?.prepareContent?.(source) ?? "",
150
+ )?.[1];
151
+ expect(partner).toContain("rate");
152
+ });
153
+
154
+ it("leaves an unlinked clip on its usual write path", async () => {
155
+ usePlayerStore.getState().setElements(elements);
156
+ const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
157
+ const onSetAttribute = vi.fn();
158
+ const probe = renderLinked(selection("music"), commit);
159
+ expect(probe.linked()).toBeNull();
160
+ await withLinkedPlaybackRate(onSetAttribute, probe.linked())("playback-rate", "2");
161
+ expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "2");
162
+ expect(commit).not.toHaveBeenCalled();
163
+ });
164
+ });
@@ -0,0 +1,94 @@
1
+ import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
2
+ import { expandToLinkedMembers } from "../../player/components/audioClipLink";
3
+ import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
4
+ import {
5
+ applyPatchByTarget,
6
+ readAttributeByTarget,
7
+ type PatchTarget,
8
+ } from "../../utils/sourcePatcher";
9
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
10
+ import { buildPatchTarget } from "../../hooks/timelineEditingHelpers";
11
+ import type { DomEditSelection } from "./domEditingTypes";
12
+
13
+ type AttributeValueFor = (current: string | undefined) => string | null;
14
+
15
+ /** Writes a speed attribute on the selection and every `data-link` partner as one undo step. */
16
+ export interface LinkedSpeedCommit {
17
+ commitAttribute: (attr: string, valueFor: AttributeValueFor, label: string) => Promise<boolean>;
18
+ }
19
+
20
+ const keyOf = (el: TimelineElement) => el.key ?? el.id;
21
+ const dataProperty = (attr: string) => (attr.startsWith("data-") ? attr.slice(5) : attr);
22
+
23
+ function isSelected(el: TimelineElement, selection: PatchTarget): boolean {
24
+ if (selection.hfId && el.hfId) return el.hfId === selection.hfId;
25
+ return selection.id != null && (el.domId ?? el.id) === selection.id;
26
+ }
27
+
28
+ export function linkedPartnerTargets(
29
+ selection: PatchTarget,
30
+ elements: readonly TimelineElement[],
31
+ ): PatchTarget[] {
32
+ const self = elements.find((el) => isSelected(el, selection));
33
+ if (!self?.link) return [];
34
+ const members = expandToLinkedMembers([keyOf(self)], elements, isLinkedSelectionOn());
35
+ members.delete(keyOf(self));
36
+ return elements
37
+ .filter((el) => members.has(keyOf(el)))
38
+ .map(buildPatchTarget)
39
+ .filter((target): target is NonNullable<typeof target> => target !== null);
40
+ }
41
+
42
+ export function fanOutAttributeInSource(
43
+ source: string,
44
+ targets: readonly PatchTarget[],
45
+ attr: string,
46
+ valueFor: AttributeValueFor,
47
+ ): string {
48
+ const property = dataProperty(attr);
49
+ return targets.reduce(
50
+ (html, target) =>
51
+ applyPatchByTarget(html, target, {
52
+ type: "attribute",
53
+ property,
54
+ value: valueFor(readAttributeByTarget(html, target, attr)),
55
+ }),
56
+ source,
57
+ );
58
+ }
59
+
60
+ /** Null when the selection has no link partner, so unlinked clips keep their usual write path. */
61
+ export function useLinkedSpeedCommit(
62
+ selection: DomEditSelection,
63
+ commit: CommitDomAttributeBatch | undefined,
64
+ ): LinkedSpeedCommit | null {
65
+ const elements = usePlayerStore((s) => s.elements);
66
+ if (!commit) return null;
67
+ const partners = linkedPartnerTargets(selection, elements);
68
+ if (partners.length === 0) return null;
69
+ return {
70
+ commitAttribute: (attr, valueFor, label) => {
71
+ const property = dataProperty(attr);
72
+ return commit(
73
+ selection,
74
+ [{ type: "attribute", property, value: valueFor(selection.dataAttributes[property]) }],
75
+ {
76
+ label,
77
+ prepareContent: (html) => fanOutAttributeInSource(html, partners, attr, valueFor),
78
+ },
79
+ );
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Routes `playback-rate` through the linked commit; every other attribute keeps `onSetAttribute`. */
85
+ export function withLinkedPlaybackRate(
86
+ onSetAttribute: (attr: string, value: string) => void | Promise<void>,
87
+ linked: LinkedSpeedCommit | null,
88
+ ): (attr: string, value: string) => void | Promise<void> {
89
+ if (!linked) return onSetAttribute;
90
+ return async (attr, value) => {
91
+ if (attr !== "playback-rate") return onSetAttribute(attr, value);
92
+ await linked.commitAttribute("data-playback-rate", () => value, "Edit speed");
93
+ };
94
+ }
@@ -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;
@@ -510,6 +511,16 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
510
511
  endStudioManualEditGesture(member.element, member.gestureToken);
511
512
  }
512
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
+
513
524
  /** Roll back a FAILED drag to the exact gesture-start state. */
514
525
  export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
515
526
  for (const member of members) {
@@ -88,9 +88,9 @@ describe("cutoutOps", () => {
88
88
  describe("mintLinkId", () => {
89
89
  it("dedupes against element ids and existing links", () => {
90
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");
91
+ expect(mintLinkId(doc)).toBe("lk-1");
92
+ doc.body.innerHTML = '<div id="lk-1"></div><video data-link="lk-2"></video>';
93
+ expect(mintLinkId(doc)).toBe("lk-3");
94
94
  });
95
95
  });
96
96
 
@@ -176,10 +176,26 @@ describe("buildKeepSoundCutoutEdit", () => {
176
176
  { target: "rate", points: [{ t: 0, v: 2 }] },
177
177
  ]);
178
178
  expect(audio.getAttribute("data-link")).toBe(video.getAttribute("data-link"));
179
- expect(video.getAttribute("data-link")).toBe("link");
179
+ expect(video.getAttribute("data-link")).toBe("lk-1");
180
+ expect(video.getAttribute("data-sync-origin")).toBe("lk-1");
181
+ expect(audio.getAttribute("data-sync-origin")).toBe("lk-1");
180
182
  expect(audio.parentElement).toBe(video.parentElement);
181
183
  });
182
184
 
185
+ it("keeps a hidden video's sound silent on the linked audio", () => {
186
+ const video = liveVideo();
187
+ video.setAttribute("data-hidden", "");
188
+ const edit = buildKeepSoundCutoutEdit({
189
+ video,
190
+ videoId: "clip",
191
+ target: { id: "clip" },
192
+ cutoutSrc: "assets/talk-cutout.webm",
193
+ });
194
+ const hidden = source.replace('<video id="clip"', '<video id="clip" data-hidden');
195
+ const doc = parse(applyEdit(hidden, edit));
196
+ expect(doc.getElementById("clip-audio")?.hasAttribute("data-hidden")).toBe(true);
197
+ });
198
+
183
199
  it("drops the video's automation attribute when it held no rate lane", () => {
184
200
  const video = makeVideo({
185
201
  id: "clip",
@@ -209,4 +225,63 @@ describe("buildKeepSoundCutoutEdit", () => {
209
225
  const doc = parse(applyEdit(taken, edit));
210
226
  expect(doc.getElementById("clip-audio-2")?.getAttribute("src")).toBe("assets/talk.mp4");
211
227
  });
228
+
229
+ it("reads the authored src from the source, not the live element", () => {
230
+ const sub = source.replace('src="assets/talk.mp4"', 'src="../assets/a.mp4"');
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(sub, edit));
238
+ expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("../assets/a.mp4");
239
+ expect(doc.getElementById("clip")?.getAttribute("src")).toBe("assets/cut.webm");
240
+ });
241
+
242
+ it("takes the first <source> child when the video has no src attribute", () => {
243
+ const withSource = source.replace(
244
+ /<video id="clip"([^>]*?) src="assets\/talk.mp4"([^>]*)><\/video>/,
245
+ '<video id="clip"$1$2><source src="assets/s.mp4" type="video/mp4"></video>',
246
+ );
247
+ const edit = buildKeepSoundCutoutEdit({
248
+ video: liveVideo(),
249
+ videoId: "clip",
250
+ target: { id: "clip" },
251
+ cutoutSrc: "assets/cut.webm",
252
+ });
253
+ const doc = parse(applyEdit(withSource, edit));
254
+ expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("assets/s.mp4");
255
+ });
256
+
257
+ it("reports when the audio insert did not land", () => {
258
+ const edit = buildKeepSoundCutoutEdit({
259
+ video: liveVideo(),
260
+ videoId: "clip",
261
+ target: { id: "missing" },
262
+ cutoutSrc: "assets/cut.webm",
263
+ });
264
+ edit.prepareContent(source);
265
+ expect(edit.audioInserted()).toBe(false);
266
+ const ok = buildKeepSoundCutoutEdit({
267
+ video: liveVideo(),
268
+ videoId: "clip",
269
+ target: { id: "clip" },
270
+ cutoutSrc: "assets/cut.webm",
271
+ });
272
+ ok.prepareContent(source);
273
+ expect(ok.audioInserted()).toBe(true);
274
+ });
275
+
276
+ it("copies loop onto the linked audio", () => {
277
+ const video = makeVideo({ id: "clip", src: "assets/talk.mp4", loop: "" });
278
+ const edit = buildKeepSoundCutoutEdit({
279
+ video,
280
+ videoId: "clip",
281
+ target: { id: "clip" },
282
+ cutoutSrc: "assets/cut.webm",
283
+ });
284
+ const doc = parse(applyEdit(source, edit));
285
+ expect(doc.getElementById("clip-audio")?.hasAttribute("loop")).toBe(true);
286
+ });
212
287
  });