@hyperframes/studio 0.8.108 → 0.8.110

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -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
+ }
@@ -35,18 +35,23 @@ function Overlay() {
35
35
  onPointerMove={marquee.onPointerMove}
36
36
  onPointerUp={marquee.onPointerUp}
37
37
  >
38
- {marquee.marqueeRect && <div data-band />}
38
+ {marquee.marqueeRect && <div data-band data-width={marquee.marqueeRect.width} />}
39
39
  </div>
40
40
  );
41
41
  }
42
42
 
43
43
  const pointer = (type: string, clientX: number, clientY: number) =>
44
44
  act(() => {
45
- document
46
- .querySelector("[data-overlay]")!
47
- .dispatchEvent(
48
- new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX, clientY }),
49
- );
45
+ document.querySelector("[data-overlay]")!.dispatchEvent(
46
+ new PointerEvent(type, {
47
+ bubbles: true,
48
+ buttons: type === "pointerup" ? 0 : 1,
49
+ button: 0,
50
+ pointerId: 1,
51
+ clientX,
52
+ clientY,
53
+ }),
54
+ );
50
55
  });
51
56
  const escape = () => {
52
57
  const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
@@ -86,6 +91,25 @@ it("an escape cancels a preview band while the pointer is still down, and stops
86
91
  expect(hostEscapes).toHaveLength(0);
87
92
  });
88
93
 
94
+ it("a buttonless move back at the start does not shrink the band", () => {
95
+ pointer("pointerdown", 10, 10);
96
+ pointer("pointermove", 120, 90);
97
+ const band = () => document.querySelector<HTMLElement>("[data-band]")?.dataset.width;
98
+ expect(band()).toBe("110");
99
+ act(() => {
100
+ document.querySelector("[data-overlay]")!.dispatchEvent(
101
+ new PointerEvent("pointermove", {
102
+ bubbles: true,
103
+ pointerId: 1,
104
+ buttons: 0,
105
+ clientX: 10,
106
+ clientY: 10,
107
+ }),
108
+ );
109
+ });
110
+ expect(band()).toBe("110");
111
+ });
112
+
89
113
  it("an escape with no band in flight still reaches the host", () => {
90
114
  const event = escape();
91
115
 
@@ -133,6 +157,7 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
133
157
  new PointerEvent(type, {
134
158
  bubbles: true,
135
159
  button: 0,
160
+ buttons: type === "pointerup" ? 0 : 1,
136
161
  pointerId: 1,
137
162
  clientX: x,
138
163
  clientY: y,
@@ -6,6 +6,7 @@ import { isElementComputedVisible } from "./domEditingElement";
6
6
  import { coversComposition } from "../../utils/studioPreviewHelpers";
7
7
  import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
8
8
  import { toVisibleOverlayRect } from "./domEditOverlayGeometry";
9
+ import { movesGesture } from "./domEditOverlayGestures";
9
10
 
10
11
  interface MarqueeState {
11
12
  startX: number;
@@ -226,10 +227,23 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
226
227
  window.removeEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
227
228
  }, [cancel]);
228
229
 
230
+ const showCandidates = useCallback(
231
+ (rect: Rect) => {
232
+ const iframe = deps.iframeRef.current;
233
+ const overlay = deps.overlayRef.current;
234
+ if (!iframe || !overlay) return;
235
+ const acp = deps.activeCompositionPathRef.current ?? "index.html";
236
+ candidatesRef.current ??= collectMarqueeCandidates(iframe, overlay, acp);
237
+ setCandidateRects(hitsWithin(rect, candidatesRef.current).map((h) => h.rect));
238
+ },
239
+ [deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
240
+ );
241
+
229
242
  const onPointerMove = useCallback(
230
243
  (event: React.PointerEvent<HTMLDivElement>) => {
231
244
  const m = marqueeRef.current;
232
245
  if (m) {
246
+ if (!movesGesture(m, event)) return;
233
247
  const oRect = deps.overlayRef.current?.getBoundingClientRect();
234
248
  if (!oRect) return;
235
249
  m.currentX = event.clientX - oRect.left;
@@ -248,18 +262,12 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
248
262
  height: Math.abs(m.currentY - m.startY),
249
263
  };
250
264
  setMarqueeRect(rect);
251
- const iframe = deps.iframeRef.current;
252
- const overlay = deps.overlayRef.current;
253
- if (iframe && overlay) {
254
- const acp = deps.activeCompositionPathRef.current ?? "index.html";
255
- candidatesRef.current ??= collectMarqueeCandidates(iframe, overlay, acp);
256
- setCandidateRects(hitsWithin(rect, candidatesRef.current).map((h) => h.rect));
257
- }
265
+ showCandidates(rect);
258
266
  return;
259
267
  }
260
268
  deps.gestures?.onPointerMove(event);
261
269
  },
262
- [deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
270
+ [deps.gestures, deps.overlayRef, showCandidates],
263
271
  );
264
272
 
265
273
  const onPointerUp = useCallback(
@@ -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",
@@ -23,13 +23,17 @@ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
23
23
  import { buildDomEditPatchTarget, type DomEditSelection } from "./domEditing";
24
24
  import { generateId } from "../../utils/generateId";
25
25
 
26
- const MEDIA_LINK_ATTR = "data-link";
26
+ import {
27
+ MEDIA_LINK_ATTR,
28
+ SYNC_ORIGIN_ATTR,
29
+ mintLinkId as mintLinkIdFrom,
30
+ } from "@hyperframes/core/media-link";
27
31
  const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
28
32
 
29
33
  const SOUND_NOT_KEPT_MESSAGE =
30
34
  "Background removed, but the sound could not be kept on a linked audio track. Undo to restore the original clip.";
31
35
 
32
- const MOVED_SOUND_ATTRS = [
36
+ export const MOVED_SOUND_ATTRS = [
33
37
  "data-volume",
34
38
  HF_AUDIO_FADE_IN_ATTR,
35
39
  HF_AUDIO_FADE_OUT_ATTR,
@@ -38,7 +42,7 @@ const MOVED_SOUND_ATTRS = [
38
42
  HF_AUDIO_GROUP_ATTR,
39
43
  ];
40
44
 
41
- const COPIED_TIMING_ATTRS = [
45
+ export const COPIED_TIMING_ATTRS = [
42
46
  "start",
43
47
  "end",
44
48
  "duration",
@@ -91,7 +95,7 @@ export function cutoutOps(input: { isVideo: boolean; cutoutSrc: string }): Patch
91
95
  return input.isVideo ? [src, htmlAttr("muted", "true"), dataAttr("has-audio", null)] : [src];
92
96
  }
93
97
 
94
- function firstFreeName(base: string, taken: Set<string>): string {
98
+ export function firstFreeName(base: string, taken: Set<string>): string {
95
99
  if (!taken.has(base)) return base;
96
100
  let n = 2;
97
101
  while (taken.has(`${base}-${n}`)) n += 1;
@@ -100,12 +104,16 @@ function firstFreeName(base: string, taken: Set<string>): string {
100
104
 
101
105
  export function mintLinkId(doc: Document): string {
102
106
  const taken = new Set<string>();
103
- for (const el of Array.from(doc.querySelectorAll(`[id], [${MEDIA_LINK_ATTR}]`))) {
104
- if (el.id) taken.add(el.id);
105
- const link = el.getAttribute(MEDIA_LINK_ATTR);
106
- if (link) taken.add(link);
107
+ const selector = `[id], [${MEDIA_LINK_ATTR}], [${SYNC_ORIGIN_ATTR}]`;
108
+ for (const el of Array.from(doc.querySelectorAll(selector))) {
109
+ for (const value of [
110
+ el.id,
111
+ el.getAttribute(MEDIA_LINK_ATTR),
112
+ el.getAttribute(SYNC_ORIGIN_ATTR),
113
+ ])
114
+ if (value) taken.add(value);
107
115
  }
108
- return firstFreeName("link", taken);
116
+ return mintLinkIdFrom(taken);
109
117
  }
110
118
 
111
119
  function firstFreeTrackIndex(source: string): number {
@@ -117,7 +125,7 @@ function firstFreeTrackIndex(source: string): number {
117
125
  return track;
118
126
  }
119
127
 
120
- function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
128
+ export function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
121
129
  let lanes: HfAutomationLane[];
122
130
  try {
123
131
  lanes = parseAutomation(raw).lanes;
@@ -132,11 +140,11 @@ function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes:
132
140
  };
133
141
  }
134
142
 
135
- function formatAttrs(attrs: Array<[string, string]>): string {
143
+ export function formatAttrs(attrs: Array<[string, string]>): string {
136
144
  return attrs.map(([name, value]) => `${name}="${escapeHtmlAttribute(value)}"`).join(" ");
137
145
  }
138
146
 
139
- function readAuthoredSrc(source: string, target: PatchTarget): string {
147
+ export function readAuthoredSrc(source: string, target: PatchTarget): string {
140
148
  const tag = findTagByTarget(source, target);
141
149
  if (!tag) return "";
142
150
  const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
@@ -145,7 +153,7 @@ function readAuthoredSrc(source: string, target: PatchTarget): string {
145
153
  return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
146
154
  }
147
155
 
148
- function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
156
+ export function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
149
157
  const match = findTagByTarget(source, target);
150
158
  if (!match) return source;
151
159
  const lineStart = source.lastIndexOf("\n", match.start) + 1;
@@ -187,7 +195,10 @@ export function buildKeepSoundCutoutEdit(input: {
187
195
  movedSound.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
188
196
  ops.push(dataAttr(stripDataPrefix(HF_AUDIO_AUTOMATION_ATTR), videoKeeps));
189
197
  }
190
- ops.push(dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId));
198
+ ops.push(
199
+ dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId),
200
+ dataAttr(stripDataPrefix(SYNC_ORIGIN_ATTR), linkId),
201
+ );
191
202
 
192
203
  const prepareContent = (source: string): string => {
193
204
  const originalSrc = readAuthoredSrc(source, target);
@@ -208,9 +219,11 @@ export function buildKeepSoundCutoutEdit(input: {
208
219
  ...timing,
209
220
  ["data-track-index", String(firstFreeTrackIndex(source))],
210
221
  [MEDIA_LINK_ATTR, linkId],
222
+ [SYNC_ORIGIN_ATTR, linkId],
211
223
  ...movedSound,
212
224
  ];
213
225
  if (video.hasAttribute("loop")) attrs.push(["loop", ""]);
226
+ if (video.hasAttribute("data-hidden")) attrs.push(["data-hidden", ""]);
214
227
  const cut = applyPatchByTarget(source, target, cutoutSrcOp);
215
228
  const inserted = insertBeforeTarget(cut, target, `<audio ${formatAttrs(attrs)}></audio>`);
216
229
  audioInserted = inserted !== cut;