@hyperframes/studio 0.8.109 → 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 (92) hide show
  1. package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-HTFVHUJ4.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CMSgrT2F.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-CYP6dMd7.js → index-Co6W8IVk.js} +205 -204
  6. package/dist/index.d.ts +21 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5351 -4755
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  13. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  14. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  15. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  16. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  17. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  19. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  20. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  21. package/src/components/editor/marqueeCommit.ts +16 -8
  22. package/src/components/editor/mediaLinkEdits.test.ts +19 -6
  23. package/src/components/editor/mediaLinkEdits.ts +34 -13
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  25. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  26. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  27. package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
  28. package/src/contexts/TimelineEditContext.tsx +1 -0
  29. package/src/editorGesturesExports.test.tsx +1 -0
  30. package/src/hooks/appHotkeysDispatch.ts +9 -0
  31. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  32. package/src/hooks/timelineElementsAttribute.ts +113 -0
  33. package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
  34. package/src/hooks/timelineLinkEditPlan.ts +6 -1
  35. package/src/hooks/useAppHotkeys.test.ts +34 -0
  36. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  37. package/src/hooks/useRenderClipContent.ts +3 -5
  38. package/src/hooks/useTimelineEditing.ts +13 -0
  39. package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
  40. package/src/hooks/useTimelineLinkEditing.ts +5 -0
  41. package/src/index.ts +4 -0
  42. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  43. package/src/player/components/AudioGainDialog.tsx +226 -0
  44. package/src/player/components/ClipBadges.test.tsx +32 -8
  45. package/src/player/components/ClipBadges.tsx +37 -31
  46. package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
  47. package/src/player/components/ClipContextMenu.tsx +1 -1
  48. package/src/player/components/ClipPeakMarks.tsx +2 -14
  49. package/src/player/components/Timeline.test.ts +15 -0
  50. package/src/player/components/TimelineClip.test.tsx +41 -0
  51. package/src/player/components/TimelineClip.tsx +3 -2
  52. package/src/player/components/TimelineLanes.test.tsx +45 -2
  53. package/src/player/components/TimelineLanes.tsx +0 -2
  54. package/src/player/components/TimelineOverlays.tsx +2 -0
  55. package/src/player/components/audioClipLink.test.ts +60 -0
  56. package/src/player/components/audioClipLink.ts +77 -0
  57. package/src/player/components/audioGainApply.test.ts +87 -0
  58. package/src/player/components/audioGainApply.ts +57 -0
  59. package/src/player/components/audioGainDialogStore.ts +36 -0
  60. package/src/player/components/audioGainPlan.test.ts +82 -0
  61. package/src/player/components/audioGainPlan.ts +53 -0
  62. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
  63. package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
  64. package/src/player/components/clipMenuAudioItems.tsx +16 -37
  65. package/src/player/components/clipMenuLinkItems.test.ts +84 -0
  66. package/src/player/components/clipMenuLinkItems.tsx +54 -9
  67. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  68. package/src/player/components/clipMenuToolItems.tsx +79 -6
  69. package/src/player/components/clipPeakMap.ts +46 -0
  70. package/src/player/components/clipPeakRuns.test.ts +14 -1
  71. package/src/player/components/clipPeakRuns.ts +11 -0
  72. package/src/player/components/clipToolAttrs.test.ts +57 -41
  73. package/src/player/components/clipToolAttrs.ts +48 -45
  74. package/src/player/components/studioShortcuts.ts +2 -0
  75. package/src/player/components/timelineCallbacks.ts +6 -0
  76. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  77. package/src/player/components/timelineClipDragPreview.ts +32 -7
  78. package/src/player/components/timelineClipGestureHandlers.ts +26 -31
  79. package/src/player/components/timelineEditing.test.ts +0 -13
  80. package/src/player/components/timelineEditing.ts +0 -11
  81. package/src/player/components/timelineLaneProps.ts +0 -5
  82. package/src/player/components/timelineLinkSelection.ts +20 -2
  83. package/src/player/components/timelineViewportGeometry.ts +3 -0
  84. package/src/player/components/useClipToolState.ts +0 -5
  85. package/src/player/components/useTimelineClipDrag.ts +12 -5
  86. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  87. package/src/player/components/useTimelineProviderState.tsx +0 -2
  88. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  89. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  90. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  91. package/src/utils/typingTarget.test.ts +8 -0
  92. package/src/utils/typingTarget.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.109",
3
+ "version": "0.8.110",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.109",
64
- "@hyperframes/parsers": "0.8.109",
65
- "@hyperframes/sdk": "0.8.109",
66
- "@hyperframes/shader-transitions": "0.8.109",
67
- "@hyperframes/player": "0.8.109",
68
- "@hyperframes/studio-server": "0.8.109"
63
+ "@hyperframes/parsers": "0.8.110",
64
+ "@hyperframes/player": "0.8.110",
65
+ "@hyperframes/core": "0.8.110",
66
+ "@hyperframes/studio-server": "0.8.110",
67
+ "@hyperframes/shader-transitions": "0.8.110",
68
+ "@hyperframes/sdk": "0.8.110"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.109"
84
+ "@hyperframes/producer": "0.8.110"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -35,6 +35,19 @@ function makeEl(id: string, clip: string): HTMLElement {
35
35
  return el;
36
36
  }
37
37
 
38
+ /** Presses at the first x, moves through the rest with their buttons, and lets go where the last held move was. */
39
+ function dragCropRight(handle: HTMLElement, pointerId: number, points: [number, number][]) {
40
+ const [[start], ...moves] = points;
41
+ const release = moves.filter(([, buttons]) => buttons & 1).at(-1)?.[0] ?? start;
42
+ const send = (type: string, clientX: number, buttons: number) =>
43
+ handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId, buttons, clientX }));
44
+ act(() => {
45
+ send("pointerdown", start, 1);
46
+ for (const [x, buttons] of moves) send("pointermove", x, buttons);
47
+ send("pointerup", release, 0);
48
+ });
49
+ }
50
+
38
51
  function render(
39
52
  el: HTMLElement,
40
53
  onStyleCommit: (property: string, value: string) => Promise<unknown> | void = () => undefined,
@@ -111,7 +124,14 @@ describe("DomEditCropHandles clip lift", () => {
111
124
  ] as const) {
112
125
  const at = { clientX: 100 + d * c.dx, clientY: 50 + d * c.dy };
113
126
  act(() =>
114
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, ...at })),
127
+ handle.dispatchEvent(
128
+ new PointerEvent(type, {
129
+ bubbles: true,
130
+ buttons: type === "pointerup" ? 0 : 1,
131
+ pointerId: 3,
132
+ ...at,
133
+ }),
134
+ ),
115
135
  );
116
136
  }
117
137
  expect(onStyleCommit).not.toHaveBeenCalled();
@@ -121,16 +141,23 @@ describe("DomEditCropHandles clip lift", () => {
121
141
  const onStyleCommit = vi.fn();
122
142
  render(makeEl("a", "inset(10px)"), onStyleCommit);
123
143
  const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
124
- act(() => {
125
- for (const [type, clientX] of [
126
- ["pointerdown", 100],
127
- ["pointermove", 90],
128
- ["pointermove", 80],
129
- ["pointerup", 80],
130
- ] as const) {
131
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 4, clientX }));
132
- }
133
- });
144
+ dragCropRight(handle, 4, [
145
+ [100, 1],
146
+ [90, 1],
147
+ [80, 1],
148
+ ]);
149
+ expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
150
+ });
151
+
152
+ it("commits where the pointer let go, not at a buttonless move back at the press point", () => {
153
+ const onStyleCommit = vi.fn();
154
+ render(makeEl("a", "inset(10px)"), onStyleCommit);
155
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
156
+ dragCropRight(handle, 6, [
157
+ [100, 1],
158
+ [80, 1],
159
+ [100, 0],
160
+ ]);
134
161
  expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
135
162
  });
136
163
 
@@ -138,16 +165,11 @@ describe("DomEditCropHandles clip lift", () => {
138
165
  const a = makeEl("a", "");
139
166
  const { root } = render(a, (property, value) => void a.style.setProperty(property, value));
140
167
  const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
141
- act(() => {
142
- for (const [type, clientX] of [
143
- ["pointerdown", 100],
144
- ["pointermove", 90],
145
- ["pointermove", 80],
146
- ["pointerup", 80],
147
- ] as const) {
148
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 5, clientX }));
149
- }
150
- });
168
+ dragCropRight(handle, 5, [
169
+ [100, 1],
170
+ [90, 1],
171
+ [80, 1],
172
+ ]);
151
173
  await act(async () => undefined);
152
174
  act(() => root.unmount());
153
175
  expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)");
@@ -163,7 +185,14 @@ describe("DomEditCropHandles clip lift", () => {
163
185
  const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
164
186
  const press = (type: string, clientX: number) =>
165
187
  act(() =>
166
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientX })),
188
+ handle.dispatchEvent(
189
+ new PointerEvent(type, {
190
+ bubbles: true,
191
+ buttons: type === "pointerup" ? 0 : 1,
192
+ pointerId: 1,
193
+ clientX,
194
+ }),
195
+ ),
167
196
  );
168
197
  press("pointerdown", 100);
169
198
  press("pointermove", 80);
@@ -17,6 +17,7 @@ import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelp
17
17
  import { readCropFollowingResize } from "./cropResize";
18
18
  import { isCropBarTarget, useCropPresetBarStore } from "./cropPresetStore";
19
19
  import { CropPresetBar } from "./CropPresetBar";
20
+ import { movesGesture } from "./domEditOverlayGestures";
20
21
 
21
22
  interface CropGestureState {
22
23
  edge: CropEdge | "move";
@@ -188,7 +189,7 @@ export function DomEditCropHandles({
188
189
 
189
190
  const updateCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
190
191
  const gesture = gestureRef.current;
191
- if (!gesture || gesture.pointerId !== event.pointerId) return;
192
+ if (!gesture || !movesGesture(gesture, event)) return;
192
193
  event.preventDefault();
193
194
  event.stopPropagation();
194
195
  const local = rotateDeltaIntoFrame(
@@ -196,6 +196,7 @@ function evt(clientX: number, clientY: number) {
196
196
  clientY,
197
197
  pointerId: 1,
198
198
  button: 0,
199
+ buttons: 1,
199
200
  altKey: false,
200
201
  shiftKey: false,
201
202
  preventDefault() {},
@@ -182,7 +182,10 @@ async function settle() {
182
182
 
183
183
  const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
184
184
  act(() => {
185
- target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, ...init }));
185
+ const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
186
+ target.dispatchEvent(
187
+ new MouseEvent(type, { bubbles: true, cancelable: true, buttons, ...init }),
188
+ );
186
189
  });
187
190
  };
188
191
 
@@ -49,6 +49,7 @@ export interface GestureState {
49
49
  kind: GestureKind;
50
50
  mode: "path-offset" | "box-size" | "rotation";
51
51
  selection: DomEditSelection;
52
+ pointerId: number;
52
53
  startX: number;
53
54
  startY: number;
54
55
  centerX: number;
@@ -93,6 +94,7 @@ export interface GestureState {
93
94
  }
94
95
 
95
96
  export interface GroupGestureState {
97
+ pointerId: number;
96
98
  startX: number;
97
99
  startY: number;
98
100
  originItems: GroupOverlayItem[];
@@ -103,6 +105,14 @@ export interface GroupGestureState {
103
105
  travelled?: boolean;
104
106
  }
105
107
 
108
+ /** Only the pressing pointer's moves with its button held drive a gesture, not Chromium's buttonless resends. */
109
+ export function movesGesture(
110
+ gesture: { pointerId: number },
111
+ e: { pointerId: number; buttons: number },
112
+ ): boolean {
113
+ return e.pointerId === gesture.pointerId && (e.buttons & 1) === 1;
114
+ }
115
+
106
116
  export interface BlockedMoveState {
107
117
  pointerId: number;
108
118
  startX: number;
@@ -177,6 +177,7 @@ describe("a rotate on a page that loads GSAP", () => {
177
177
  clientY,
178
178
  pointerId: 1,
179
179
  button: 0,
180
+ buttons: 1,
180
181
  shiftKey: false,
181
182
  preventDefault() {},
182
183
  stopPropagation() {},
@@ -119,6 +119,7 @@ export function startGroupDrag(
119
119
  e.currentTarget.setPointerCapture(e.pointerId);
120
120
  opts.rafPausedRef.current = true;
121
121
  opts.groupGestureRef.current = {
122
+ pointerId: e.pointerId,
122
123
  startX: e.clientX,
123
124
  startY: e.clientY,
124
125
  originItems: items,
@@ -265,6 +266,7 @@ export function startGesture(
265
266
  kind,
266
267
  mode,
267
268
  selection: sel,
269
+ pointerId: e.pointerId,
268
270
  startX: e.clientX,
269
271
  startY: e.clientY,
270
272
  centerX,
@@ -66,8 +66,24 @@ const byId = (iframe: HTMLIFrameElement | null, id: string) =>
66
66
  iframe!.contentDocument!.getElementById(id) as HTMLElement;
67
67
  const api = (): TimelinePlayerApi => player.getApi();
68
68
 
69
- function pointer(target: Element, type: string, x: number, y: number) {
70
- const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: x, clientY: y };
69
+ function pointer(
70
+ target: Element,
71
+ type: string,
72
+ x: number,
73
+ y: number,
74
+ extra: PointerEventInit = {},
75
+ ) {
76
+ const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
77
+ const init = {
78
+ bubbles: true,
79
+ cancelable: true,
80
+ button: 0,
81
+ buttons,
82
+ pointerId: 1,
83
+ clientX: x,
84
+ clientY: y,
85
+ ...extra,
86
+ };
71
87
  act(() => void target.dispatchEvent(new PointerEvent(type, init)));
72
88
  }
73
89
 
@@ -252,6 +268,27 @@ describe("a reload during a drag", () => {
252
268
  });
253
269
  });
254
270
 
271
+ describe("a drag moves only with its own pointer pressed", () => {
272
+ it.each([
273
+ ["no button held, as Chromium sends after a layout change", { buttons: 0 }],
274
+ ["another pointer", { pointerId: 2 }],
275
+ ])(
276
+ "a move with %s at a fixed point leaves the drop where the pointer let go",
277
+ async (_, stray) => {
278
+ const { live, overlay, box } = mountEditor(false);
279
+ pointer(box, "pointerdown", 150, 150);
280
+ pointer(overlay, "pointermove", 170, 160);
281
+ pointer(overlay, "pointermove", 20, 20, stray);
282
+ pointer(overlay, "pointermove", 190, 170);
283
+ pointer(overlay, "pointermove", 20, 20, stray);
284
+ expect(byId(live, "title").style.getPropertyValue("translate")).toBe("40px 20px");
285
+ pointer(overlay, "pointerup", 190, 170);
286
+ await settle();
287
+ expect(file.title).toBe("translate: 40px 20px");
288
+ },
289
+ );
290
+ });
291
+
255
292
  type Editor = ReturnType<typeof mountEditor>;
256
293
 
257
294
  describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {
@@ -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(
@@ -10,6 +10,7 @@ import {
10
10
  pickDetachedAudioTrack,
11
11
  removeElementInSource,
12
12
  setLinkInSource,
13
+ sharesSourceFile,
13
14
  } from "./mediaLinkEdits";
14
15
 
15
16
  const automation = JSON.stringify({
@@ -192,6 +193,15 @@ describe("linkInSource", () => {
192
193
  });
193
194
  });
194
195
 
196
+ describe("linkInSource without a sync origin", () => {
197
+ it("writes only data-link", () => {
198
+ const src = '<video id="v"></video><audio id="a"></audio>';
199
+ expect(linkInSource(src, [{ id: "v" }, { id: "a" }], { syncOrigin: false })).toBe(
200
+ '<video id="v" data-link="lk-1"></video><audio id="a" data-link="lk-1"></audio>',
201
+ );
202
+ });
203
+ });
204
+
195
205
  describe("setLinkInSource", () => {
196
206
  it("writes and removes data-link on every target", () => {
197
207
  const src = '<video id="v"></video><audio id="a"></audio>';
@@ -248,9 +258,12 @@ describe("predicates", () => {
248
258
  expect(findMergePair(v, [v, el("a", "audio", { src: "other.mp4" })])).toBeNull();
249
259
  });
250
260
 
251
- it("links exactly one video and one audio of the same file and timing", () => {
261
+ it("links exactly one unlinked video and one audio, whatever their timing or file", () => {
252
262
  expect(canLinkPair([el("v", "video"), el("a", "audio")])).toBe(true);
253
- expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3 })])).toBe(false);
263
+ expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3, duration: 2 })])).toBe(true);
264
+ expect(canLinkPair([el("v", "video"), el("a", "audio", { src: "other.mp3" })])).toBe(true);
265
+ expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio")])).toBe(false);
266
+ expect(canLinkPair([el("v", "video"), el("a", "audio", { link: "y" })])).toBe(false);
254
267
  expect(canLinkPair([el("v", "video"), el("w", "video")])).toBe(false);
255
268
  expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio", { link: "x" })])).toBe(
256
269
  false,
@@ -266,16 +279,16 @@ describe("predicates compare the whole asset path", () => {
266
279
  const v = el("v", "video", { muted: true, ...one });
267
280
  const a = el("a", "audio", two);
268
281
  expect(findMergePair(v, [v, a])).toBeNull();
269
- expect(canLinkPair([el("v", "video", one), el("a", "audio", two)])).toBe(false);
282
+ expect(sharesSourceFile([el("v", "video", one), el("a", "audio", two)])).toBe(false);
270
283
  });
271
284
 
272
285
  it("resolves each src against its own source file, and links only inside one", () => {
273
286
  const v = el("v", "video", { src: "../assets/talk.mp4", sourceFile: "scenes/a.html" });
274
287
  const a = el("a", "audio", { src: "./assets/talk.mp4", sourceFile: "index.html" });
275
288
  expect(canLinkPair([v, a])).toBe(false);
276
- expect(
277
- canLinkPair([v, { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" }]),
278
- ).toBe(true);
289
+ expect(sharesSourceFile([v, a])).toBe(false);
290
+ const sameFileAudio = { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" };
291
+ expect(sharesSourceFile([v, sameFileAudio])).toBe(true);
279
292
  });
280
293
 
281
294
  it("does not take a same-id link from another source file as the merge partner", () => {
@@ -1,5 +1,10 @@
1
1
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
2
- import { MEDIA_LINK_ATTR, SYNC_ORIGIN_ATTR, mintLinkId } from "@hyperframes/core/media-link";
2
+ import {
3
+ MEDIA_LINK_ATTR,
4
+ SYNC_ORIGIN_ATTR,
5
+ mintLinkId,
6
+ sourceZeroTime,
7
+ } from "@hyperframes/core/media-link";
3
8
  import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement";
4
9
  import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink";
5
10
  import {
@@ -62,14 +67,17 @@ export function setLinkInSource(
62
67
  );
63
68
  }
64
69
 
65
- /** Link the targets under one freshly minted id, which is also their sync origin. */
66
- export function linkInSource(source: string, targets: readonly PatchTarget[]): string {
70
+ /** Link the targets under one fresh id, also their sync origin when they share a source file. */
71
+ export function linkInSource(
72
+ source: string,
73
+ targets: readonly PatchTarget[],
74
+ options: { syncOrigin: boolean } = { syncOrigin: true },
75
+ ): string {
67
76
  const linkId = mintLinkIdForSource(source);
68
- return targets.reduce(
69
- (html, target) =>
70
- applyOps(html, target, [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]),
71
- source,
72
- );
77
+ const ops = options.syncOrigin
78
+ ? [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]
79
+ : [dataOp(LINK_PROPERTY, linkId)];
80
+ return targets.reduce((html, target) => applyOps(html, target, ops), source);
73
81
  }
74
82
 
75
83
  /** Remove one element (open tag through its matching close tag) and its line's indentation. */
@@ -242,15 +250,28 @@ export function findMergePair<T extends TimedElement>(
242
250
  return video.muted === true ? { video, audio } : null;
243
251
  }
244
252
 
245
- /** Exactly one video and one audio, same file, identical timing, not already linked together. */
253
+ /** One video and one audio in one composition, neither linked: timing and file don't matter. */
246
254
  export function canLinkPair(selected: readonly TimedElement[]): boolean {
247
255
  if (selected.length !== 2) return false;
248
256
  const [a, b] = selected;
249
- if (!a || !b) return false;
257
+ if (!a || !b || a.link || b.link) return false;
250
258
  const tags = new Set([tagOf(a), tagOf(b)]);
251
- if (!tags.has("video") || !tags.has("audio")) return false;
252
- if (sharesLinkGroup(a, b)) return false;
253
- return sameAssetInScope(a, b) && hasIdenticalTiming(a, b);
259
+ return tags.has("video") && tags.has("audio") && sameCompositionScope(a, b);
260
+ }
261
+
262
+ export function sharesSourceFile(selected: readonly TimedElement[]): boolean {
263
+ const [a, b] = selected;
264
+ return selected.length === 2 && !!a && !!b && sameAssetInScope(a, b);
265
+ }
266
+
267
+ export function isPairInSync(a: TimedElement, b: TimedElement): boolean {
268
+ const timing = (el: TimedElement) => ({
269
+ start: el.start,
270
+ mediaStart: el.playbackStart ?? 0,
271
+ playbackRate: el.playbackRate ?? 1,
272
+ });
273
+ const [x, y] = [timing(a), timing(b)];
274
+ return near(x.playbackRate, y.playbackRate) && near(sourceZeroTime(x), sourceZeroTime(y));
254
275
  }
255
276
 
256
277
  type TrackedElement = Pick<TimelineElement, "tag" | "track" | "start" | "duration"> & {
@@ -105,7 +105,14 @@ function fixture(
105
105
  }
106
106
 
107
107
  const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
108
- const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, ...init });
108
+ const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
109
+ const event = new MouseEvent(type, {
110
+ bubbles: true,
111
+ cancelable: true,
112
+ button: 0,
113
+ buttons,
114
+ ...init,
115
+ });
109
116
  act(() => {
110
117
  target.dispatchEvent(event);
111
118
  });
@@ -31,7 +31,7 @@ function gestureHarness(gesture: Partial<GestureState> | null, group: GroupGestu
31
31
  };
32
32
  const handlers = createDomEditOverlayGestureHandlers(opts as never);
33
33
  const pointer = (clientX: number, clientY: number, shiftKey: boolean) =>
34
- ({ clientX, clientY, shiftKey, altKey: false }) as never;
34
+ ({ clientX, clientY, shiftKey, altKey: false, pointerId: 1, buttons: 1 }) as never;
35
35
  return { opts, handlers, pointer };
36
36
  }
37
37
 
@@ -41,6 +41,7 @@ function singleDrag() {
41
41
  {
42
42
  kind: "drag",
43
43
  mode: "path-offset",
44
+ pointerId: 1,
44
45
  selection: { element } as unknown as DomEditSelection,
45
46
  startX: 100,
46
47
  startY: 100,
@@ -57,7 +58,13 @@ function singleDrag() {
57
58
  }
58
59
 
59
60
  const groupGesture = () =>
60
- ({ startX: 100, startY: 100, originItems: [], members: [] }) as unknown as GroupGestureState;
61
+ ({
62
+ pointerId: 1,
63
+ startX: 100,
64
+ startY: 100,
65
+ originItems: [],
66
+ members: [],
67
+ }) as unknown as GroupGestureState;
61
68
 
62
69
  describe("shift+drag locks a move to the axis the pointer travels further on", () => {
63
70
  it("keeps a mostly horizontal single drag on its row", () => {
@@ -278,6 +285,7 @@ function singleMemberDrag() {
278
285
  {
279
286
  kind: "drag",
280
287
  mode: "path-offset",
288
+ pointerId: 1,
281
289
  selection,
282
290
  startX: 100,
283
291
  startY: 100,
@@ -300,7 +308,13 @@ function singleMemberDrag() {
300
308
  function groupMemberDrag() {
301
309
  const a = draggableMember("a");
302
310
  const b = draggableMember("b");
303
- const group = { startX: 100, startY: 100, originItems: [], members: [a.member, b.member] };
311
+ const group = {
312
+ pointerId: 1,
313
+ startX: 100,
314
+ startY: 100,
315
+ originItems: [],
316
+ members: [a.member, b.member],
317
+ };
304
318
  const harness = gestureHarness(null, group as unknown as GroupGestureState);
305
319
  return { ...harness, at: () => [a.at(), b.at()] };
306
320
  }
@@ -38,6 +38,7 @@ import {
38
38
  ROTATED_SNAP_BYPASS_DEGREES,
39
39
  hasDomEditRotationChanged,
40
40
  lockDragToDominantAxis,
41
+ movesGesture,
41
42
  resolveDomEditRotationGesture,
42
43
  } from "./domEditOverlayGestures";
43
44
  import { resolveCenterResizeSize } from "./domEditResizeLocal";
@@ -131,6 +132,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
131
132
  if (!blockedMove && !g && !groupG) {
132
133
  opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
133
134
  }
135
+ const held = g ?? groupG;
136
+ if (held && !movesGesture(held, e)) return;
134
137
 
135
138
  if (blockedMove) {
136
139
  const dx = e.clientX - blockedMove.startX;
@@ -72,6 +72,7 @@ export interface TimelineEditCallbackDeps {
72
72
  value: string | null,
73
73
  label: string,
74
74
  ) => Promise<TimelineEditOutcome | void>;
75
+ setMany?: TimelineEditCallbacks["onSetElementsAttributeQuiet"];
75
76
  };
76
77
  }
77
78
 
@@ -228,6 +229,7 @@ export function useTimelineEditCallbacks({
228
229
  onGroupClips: handleGroupClips,
229
230
  onSetElementAttributeLive: setElementFxAttribute?.setLive,
230
231
  onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
232
+ onSetElementsAttributeQuiet: setElementFxAttribute?.setMany,
231
233
  onRevertElementAttributeLive: setElementFxAttribute?.revertLive,
232
234
  onBlockedEditAttempt: handleBlockedTimelineEdit,
233
235
  onSplitElement: handleTimelineElementSplit,
@@ -37,6 +37,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
37
37
  onGroupClips: true,
38
38
  onSetElementAttributeLive: true,
39
39
  onSetElementAttributeQuiet: true,
40
+ onSetElementsAttributeQuiet: true,
40
41
  onRevertElementAttributeLive: true,
41
42
  onBlockedEditAttempt: true,
42
43
  onLinkEdit: true,