@hyperframes/studio 0.8.82 → 0.8.83

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 (76) hide show
  1. package/dist/assets/{hyperframes-player-zGIbZhLJ.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-BiLCTunu.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-DSazIt7R.js → index-BYhK_KMc.js} +193 -193
  4. package/dist/assets/{index-DStmfnpZ.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +669 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +20501 -23167
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  29. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  30. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  31. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  32. package/src/components/editor/DomEditOverlay.tsx +36 -21
  33. package/src/components/editor/LayersPanel.tsx +1 -1
  34. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  35. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  36. package/src/components/editor/clipAudioClock.test.ts +61 -0
  37. package/src/components/editor/clipAudioClock.ts +78 -0
  38. package/src/components/editor/marqueeCommit.ts +100 -37
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  40. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
  41. package/src/components/editor/useFxCarve.ts +11 -15
  42. package/src/components/editor/useFxCarveNodes.ts +27 -15
  43. package/src/components/editor/useFxLevelling.ts +7 -43
  44. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  45. package/src/components/feedback/projectProvenance.ts +2 -3
  46. package/src/components/nle/PreviewOverlays.tsx +7 -188
  47. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  48. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  49. package/src/components/ui/HyperframesLogo.tsx +120 -0
  50. package/src/domEditExports.test.tsx +69 -0
  51. package/src/editorGesturesExports.test.tsx +236 -0
  52. package/src/hooks/useElementLifecycleOps.ts +1 -1
  53. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  54. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  55. package/src/hooks/usePreviewPersistence.ts +1 -1
  56. package/src/index.ts +45 -32
  57. package/src/lightEntries.test.ts +32 -0
  58. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  59. package/src/player/components/TimelineClipFades.tsx +2 -1
  60. package/src/player/components/TimelineTypes.ts +4 -2
  61. package/src/player/components/studioShortcuts.ts +8 -5
  62. package/src/player/components/timelineClipChildren.test.ts +34 -1
  63. package/src/player/components/timelineClipChildren.tsx +7 -4
  64. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  65. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  66. package/src/player/components/timelinePointerRelease.ts +4 -0
  67. package/src/player/components/useTimelineProviderState.tsx +4 -0
  68. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  69. package/src/player/components/useTimelineStackingSync.ts +18 -11
  70. package/src/playerEntry.ts +1 -0
  71. package/src/timelineStackingSyncExport.test.tsx +67 -0
  72. package/src/ui.ts +31 -0
  73. package/src/utils/mediaTypes.test.ts +33 -0
  74. package/src/utils/mediaTypes.ts +12 -5
  75. package/dist/chunk-I7XNAIOF.js.map +0 -1
  76. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -14,6 +14,7 @@ interface MarqueeState {
14
14
  currentY: number;
15
15
  pointerId: number;
16
16
  pastThreshold: boolean;
17
+ target: Element;
17
18
  }
18
19
 
19
20
  const MARQUEE_THRESHOLD_PX = 4;
@@ -80,14 +81,13 @@ function hitsWithin(rect: Rect, candidates: MarqueeHit[]): MarqueeHit[] {
80
81
  return candidates.filter((candidate) => rectsOverlap(rect, candidate.rect));
81
82
  }
82
83
 
83
- async function runMarqueeIntersection(
84
- rect: Rect,
85
- candidates: MarqueeHit[],
84
+ async function resolveDomEditSelections(
85
+ elements: HTMLElement[],
86
86
  activeCompositionPath: string,
87
87
  ): Promise<DomEditSelection[]> {
88
88
  const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
89
89
  const hits: DomEditSelection[] = [];
90
- for (const { element } of hitsWithin(rect, candidates)) {
90
+ for (const element of elements) {
91
91
  const sel = await resolveDomEditSelection(element, {
92
92
  activeCompositionPath,
93
93
  isMasterView,
@@ -98,23 +98,48 @@ async function runMarqueeIntersection(
98
98
  return hits;
99
99
  }
100
100
 
101
- interface MarqueeGesturesDeps {
101
+ export interface MarqueeGesturesDeps<T = DomEditSelection> {
102
102
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
103
103
  overlayRef: React.RefObject<HTMLDivElement | null>;
104
104
  activeCompositionPathRef: React.RefObject<string | null>;
105
- onMarqueeSelectRef: React.RefObject<
106
- ((selections: DomEditSelection[], additive: boolean) => void) | undefined
107
- >;
108
- selectionRef: React.RefObject<DomEditSelection | null>;
109
- gestures: {
105
+ onMarqueeSelectRef: React.RefObject<((selections: T[], additive: boolean) => void) | undefined>;
106
+ /** Turns the elements a drag touched into picks; without it, Studio's edit selections. */
107
+ resolveHits?: (elements: HTMLElement[]) => T[] | Promise<T[]>;
108
+ selectionRef?: React.RefObject<DomEditSelection | null>;
109
+ /** Studio's pointer handling, for the events that are not part of a marquee. */
110
+ gestures?: {
110
111
  onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
111
112
  onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
112
113
  clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
113
114
  };
114
115
  }
115
116
 
117
+ export interface MarqueeGestures {
118
+ marqueeRect: Rect | null;
119
+ candidateRects: Rect[];
120
+ /** Starts a marquee at this press; the host has decided it landed on empty canvas. */
121
+ begin: (event: React.PointerEvent<HTMLElement>) => void;
122
+ /** Drops an active marquee without selecting anything. */
123
+ cancel: () => void;
124
+ onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
125
+ onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
126
+ onPointerCancel: () => void;
127
+ }
128
+
129
+ function releaseCapture(m: MarqueeState): void {
130
+ try {
131
+ m.target.releasePointerCapture(m.pointerId);
132
+ } catch {
133
+ /* already released */
134
+ }
135
+ }
136
+
137
+ export function useMarqueeGestures(deps: MarqueeGesturesDeps): MarqueeGestures;
138
+ export function useMarqueeGestures<T>(
139
+ deps: MarqueeGesturesDeps<T> & Required<Pick<MarqueeGesturesDeps<T>, "resolveHits">>,
140
+ ): MarqueeGestures;
116
141
  // fallow-ignore-next-line complexity
117
- export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
142
+ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGestures {
118
143
  const marqueeRef = useRef<MarqueeState | null>(null);
119
144
  const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
120
145
  // Live "candidate" highlight: the elements the marquee currently touches,
@@ -135,12 +160,55 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
135
160
  if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
136
161
  const acp = deps.activeCompositionPathRef.current ?? "index.html";
137
162
  const candidates = candidatesRef.current ?? collectMarqueeCandidates(iframe, overlay, acp);
138
- const hits = await runMarqueeIntersection(rect, candidates, acp);
139
- deps.onMarqueeSelectRef.current(hits, additive);
163
+ const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
164
+ const resolveHits = deps.resolveHits;
165
+ const picks = resolveHits
166
+ ? await resolveHits(elements)
167
+ : ((await resolveDomEditSelections(elements, acp)) as T[]);
168
+ deps.onMarqueeSelectRef.current?.(picks, additive);
140
169
  },
141
- [deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
170
+ [
171
+ deps.iframeRef,
172
+ deps.overlayRef,
173
+ deps.onMarqueeSelectRef,
174
+ deps.activeCompositionPathRef,
175
+ deps.resolveHits,
176
+ ],
142
177
  );
143
178
 
179
+ const reset = useCallback(() => {
180
+ marqueeRef.current = null;
181
+ setMarqueeRect(null);
182
+ setCandidateRects([]);
183
+ candidatesRef.current = null;
184
+ }, []);
185
+
186
+ const begin = useCallback(
187
+ (event: React.PointerEvent<HTMLElement>) => {
188
+ const oRect = deps.overlayRef.current?.getBoundingClientRect();
189
+ if (!oRect) return;
190
+ event.currentTarget.setPointerCapture(event.pointerId);
191
+ const x = event.clientX - oRect.left;
192
+ const y = event.clientY - oRect.top;
193
+ marqueeRef.current = {
194
+ startX: x,
195
+ startY: y,
196
+ currentX: x,
197
+ currentY: y,
198
+ pointerId: event.pointerId,
199
+ pastThreshold: false,
200
+ target: event.currentTarget,
201
+ };
202
+ },
203
+ [deps.overlayRef],
204
+ );
205
+
206
+ const cancel = useCallback(() => {
207
+ if (!marqueeRef.current) return;
208
+ releaseCapture(marqueeRef.current);
209
+ reset();
210
+ }, [reset]);
211
+
144
212
  const onPointerMove = useCallback(
145
213
  (event: React.PointerEvent<HTMLDivElement>) => {
146
214
  const m = marqueeRef.current;
@@ -172,7 +240,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
172
240
  }
173
241
  return;
174
242
  }
175
- deps.gestures.onPointerMove(event);
243
+ deps.gestures?.onPointerMove(event);
176
244
  },
177
245
  [deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
178
246
  );
@@ -181,12 +249,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
181
249
  (event: React.PointerEvent<HTMLDivElement>) => {
182
250
  const m = marqueeRef.current;
183
251
  if (m) {
184
- marqueeRef.current = null;
185
- try {
186
- (event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
187
- } catch {
188
- /* already released */
189
- }
252
+ releaseCapture(m);
190
253
  if (m.pastThreshold) {
191
254
  commitMarquee(
192
255
  {
@@ -200,26 +263,26 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
200
263
  } else {
201
264
  deps.onMarqueeSelectRef.current?.([], false);
202
265
  }
203
- setMarqueeRect(null);
204
- setCandidateRects([]);
205
- candidatesRef.current = null;
266
+ reset();
206
267
  return;
207
268
  }
208
- deps.gestures.onPointerUp(event);
269
+ deps.gestures?.onPointerUp(event);
209
270
  },
210
- [deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
271
+ [deps.gestures, commitMarquee, deps.onMarqueeSelectRef, reset],
211
272
  );
212
273
 
213
274
  const onPointerCancel = useCallback(() => {
214
- if (marqueeRef.current) {
215
- marqueeRef.current = null;
216
- setMarqueeRect(null);
217
- setCandidateRects([]);
218
- candidatesRef.current = null;
219
- return;
220
- }
221
- deps.gestures.clearPointerState(deps.selectionRef);
222
- }, [deps.gestures, deps.selectionRef]);
223
-
224
- return { marqueeRef, marqueeRect, candidateRects, onPointerMove, onPointerUp, onPointerCancel };
275
+ if (marqueeRef.current) return cancel();
276
+ if (deps.selectionRef) deps.gestures?.clearPointerState(deps.selectionRef);
277
+ }, [deps.gestures, deps.selectionRef, cancel]);
278
+
279
+ return {
280
+ marqueeRect,
281
+ candidateRects,
282
+ begin,
283
+ cancel,
284
+ onPointerMove,
285
+ onPointerUp,
286
+ onPointerCancel,
287
+ };
225
288
  }
@@ -13,7 +13,19 @@ import { DomEditOverlay } from "./DomEditOverlay";
13
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
14
 
15
15
  const RECT = { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
16
- const layout = vi.hoisted(() => ({ group: [] as unknown[] }));
16
+ const layout = vi.hoisted(() => ({
17
+ group: [] as unknown[],
18
+ hover: null as unknown,
19
+ offCanvas: [] as unknown[],
20
+ offCanvasElements: new Map<string, HTMLElement>(),
21
+ }));
22
+
23
+ vi.mock("./useOffCanvasIndicators", () => ({
24
+ useOffCanvasIndicators: () => ({
25
+ offCanvasRects: layout.offCanvas,
26
+ offCanvasElementsRef: { current: layout.offCanvasElements },
27
+ }),
28
+ }));
17
29
 
18
30
  vi.mock("./useDomEditOverlayRects", () => ({
19
31
  useDomEditOverlayRects: () => ({
@@ -22,7 +34,7 @@ vi.mock("./useDomEditOverlayRects", () => ({
22
34
  current: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
23
35
  },
24
36
  setOverlayRect: () => undefined,
25
- hoverRect: null,
37
+ hoverRect: layout.hover,
26
38
  groupOverlayItems: layout.group,
27
39
  groupOverlayItemsRef: { current: layout.group },
28
40
  setGroupOverlayItems: () => undefined,
@@ -57,6 +69,8 @@ function fixture(
57
69
  onStyleCommit: vi.fn(),
58
70
  onDeleteSelection: vi.fn(),
59
71
  onApplyZIndex: vi.fn(),
72
+ onMarqueeSelect: vi.fn(),
73
+ onTextEditingChange: vi.fn(),
60
74
  };
61
75
  const selection = makeSelection("Title", textElement("title"));
62
76
  selection.capabilities.canApplyManualRotation = true;
@@ -92,6 +106,33 @@ const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
92
106
  return event;
93
107
  };
94
108
 
109
+ const pressEnter = (overlay: HTMLElement) =>
110
+ act(() => {
111
+ overlay.dispatchEvent(
112
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
113
+ );
114
+ });
115
+
116
+ const clickBox = (overlay: HTMLElement) => {
117
+ const box = overlay.querySelector(BOX)!;
118
+ for (const type of ["pointerdown", "pointerup", "click"]) fire(box, type);
119
+ };
120
+
121
+ const enterOn = (target: Element) =>
122
+ act(() => {
123
+ target.dispatchEvent(
124
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
125
+ );
126
+ });
127
+
128
+ const rightClick = async (overlay: HTMLElement) => {
129
+ await act(async () => {
130
+ overlay.dispatchEvent(
131
+ new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
132
+ );
133
+ });
134
+ };
135
+
95
136
  beforeEach(() => {
96
137
  vi.useFakeTimers();
97
138
  __resetForTests();
@@ -102,6 +143,9 @@ afterEach(() => {
102
143
  act(() => root.unmount());
103
144
  document.body.innerHTML = "";
104
145
  layout.group = [];
146
+ layout.hover = null;
147
+ layout.offCanvas = [];
148
+ layout.offCanvasElements.clear();
105
149
  vi.useRealTimers();
106
150
  });
107
151
 
@@ -196,13 +240,6 @@ describe("DomEditOverlay with the preview read-only", () => {
196
240
  expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
197
241
  });
198
242
 
199
- const pressEnter = (overlay: HTMLElement) =>
200
- act(() => {
201
- overlay.dispatchEvent(
202
- new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
203
- );
204
- });
205
-
206
243
  it("control: with the flag off Enter opens the text for editing", () => {
207
244
  const { selection, overlay } = fixture();
208
245
  pressEnter(overlay);
@@ -215,14 +252,6 @@ describe("DomEditOverlay with the preview read-only", () => {
215
252
  expect(selection.element.hasAttribute("contenteditable")).toBe(false);
216
253
  });
217
254
 
218
- const rightClick = async (overlay: HTMLElement) => {
219
- await act(async () => {
220
- overlay.dispatchEvent(
221
- new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
222
- );
223
- });
224
- };
225
-
226
255
  it("control: with the flag off right-click offers delete and z-order", async () => {
227
256
  const { overlay } = fixture();
228
257
  await rightClick(overlay);
@@ -258,3 +287,177 @@ describe("DomEditOverlay with the preview read-only", () => {
258
287
  expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
259
288
  });
260
289
  });
290
+
291
+ describe("DomEditOverlay with canvasInput host", () => {
292
+ const HOST = { canvasInput: "host" } as const;
293
+
294
+ it("lets presses through its root, and the overlay default keeps them", () => {
295
+ expect(fixture().overlay.className).toContain("pointer-events-auto");
296
+ act(() => root.unmount());
297
+ expect(fixture(HOST).overlay.className).toContain("pointer-events-none");
298
+ });
299
+
300
+ it("draws no hover box", () => {
301
+ layout.hover = RECT;
302
+ const hovered = (props = {}) => {
303
+ const { overlay } = fixture(props);
304
+ const box = overlay.querySelector('[data-dom-edit-hover-box="true"]');
305
+ act(() => root.unmount());
306
+ return box;
307
+ };
308
+ const selection = makeSelection("Hover", textElement("hover"));
309
+ expect(hovered({ hoverSelection: selection })).not.toBeNull();
310
+ expect(hovered({ ...HOST, hoverSelection: selection })).toBeNull();
311
+ });
312
+
313
+ it("starts no marquee and makes no selection from a press on empty canvas", () => {
314
+ const off = fixture({ selection: null });
315
+ fire(off.overlay, "pointerdown");
316
+ fire(off.overlay, "pointerup");
317
+ expect(off.spies.onMarqueeSelect).toHaveBeenCalledWith([], false);
318
+ act(() => root.unmount());
319
+ const { spies, overlay } = fixture({ ...HOST, selection: null });
320
+ fire(overlay, "pointerdown");
321
+ fire(overlay, "mousedown");
322
+ fire(overlay, "pointerup");
323
+ expect(spies.onMarqueeSelect).not.toHaveBeenCalled();
324
+ expect(spies.onCanvasMouseDown).not.toHaveBeenCalled();
325
+ });
326
+
327
+ it("opens no context menu and selects nothing on right-click", async () => {
328
+ const { spies, overlay } = fixture({ ...HOST, selection: null });
329
+ await rightClick(overlay);
330
+ expect(spies.onSelectionChange).not.toHaveBeenCalled();
331
+ act(() => root.unmount());
332
+ await rightClick(fixture(HOST).overlay);
333
+ expect(document.body.textContent).not.toContain("Delete");
334
+ });
335
+
336
+ it("does not re-select on a click of the selection box", () => {
337
+ const off = fixture();
338
+ clickBox(off.overlay);
339
+ expect(off.spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
340
+ act(() => root.unmount());
341
+ const { spies, overlay } = fixture(HOST);
342
+ clickBox(overlay);
343
+ fire(overlay.querySelector(BOX)!, "click");
344
+ expect(spies.onCanvasMouseDown).not.toHaveBeenCalled();
345
+ });
346
+
347
+ it("offers no off-canvas indicator to press, so nothing re-selects from one", async () => {
348
+ const MARK = '[aria-label="Select off-canvas element stray"]';
349
+ const offCanvas = (props = {}) => {
350
+ layout.offCanvasElements.set("stray", textElement("stray"));
351
+ layout.offCanvas = [{ key: "stray", left: 820, top: 10, width: 120, height: 40 }];
352
+ return fixture(props);
353
+ };
354
+ const off = offCanvas();
355
+ fire(off.overlay.querySelector(MARK)!, "click");
356
+ await vi.waitFor(() => expect(off.spies.onSelectionChange).toHaveBeenCalledTimes(1));
357
+ act(() => root.unmount());
358
+ const { spies, overlay } = offCanvas(HOST);
359
+ expect(overlay.querySelector(MARK)).toBeNull();
360
+ expect(spies.onSelectionChange).not.toHaveBeenCalled();
361
+ });
362
+
363
+ it("reports each selection-box click to the host once, by either route", () => {
364
+ const cases = [
365
+ { props: {}, movable: true, reselects: 1 },
366
+ { props: HOST, movable: true, reselects: 0 },
367
+ { props: HOST, movable: false, reselects: 0 },
368
+ ];
369
+ for (const { props, movable, reselects } of cases) {
370
+ const onSelectionBoxClick = vi.fn();
371
+ const { spies, selection, overlay } = fixture({ ...props, onSelectionBoxClick });
372
+ selection.capabilities.canApplyManualOffset = movable;
373
+ clickBox(overlay);
374
+ expect(onSelectionBoxClick).toHaveBeenCalledTimes(1);
375
+ expect(onSelectionBoxClick).toHaveBeenCalledWith(expect.anything(), selection);
376
+ expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(reselects);
377
+ act(() => root.unmount());
378
+ }
379
+ });
380
+
381
+ it("does not report a press off the box, or a click on a group's box", () => {
382
+ const onSelectionBoxClick = vi.fn();
383
+ const off = fixture({ onSelectionBoxClick, onMarqueeSelect: undefined });
384
+ for (const type of ["pointerdown", "mousedown", "pointerup", "click"]) fire(off.overlay, type);
385
+ expect(off.spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
386
+ act(() => root.unmount());
387
+ const members = ["a", "b"].map((id) => {
388
+ const selection = makeSelection(id, textElement(id));
389
+ return { key: id, selection, element: selection.element, rect: RECT };
390
+ });
391
+ members[1].selection.capabilities.canApplyManualOffset = false;
392
+ layout.group = members;
393
+ const { spies, overlay } = fixture({
394
+ onSelectionBoxClick,
395
+ selection: members[0].selection,
396
+ groupSelections: members.map((m) => m.selection),
397
+ });
398
+ clickBox(overlay);
399
+ expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
400
+ expect(onSelectionBoxClick).not.toHaveBeenCalled();
401
+ });
402
+
403
+ it("reports the first box click after a handle resize", () => {
404
+ const onSelectionBoxClick = vi.fn();
405
+ const { selection, overlay } = fixture({ ...HOST, onSelectionBoxClick });
406
+ for (const type of ["pointerdown", "pointerup", "click"]) {
407
+ fire(overlay.querySelector("div.h-4.w-4")!, type);
408
+ }
409
+ selection.capabilities.canApplyManualOffset = false;
410
+ clickBox(overlay);
411
+ expect(onSelectionBoxClick).toHaveBeenCalledTimes(1);
412
+ });
413
+
414
+ it("opens the text on Enter when nothing else has focus, not from a focused field", () => {
415
+ const off = fixture();
416
+ enterOn(document.body);
417
+ expect(off.selection.element.hasAttribute("contenteditable")).toBe(false);
418
+ act(() => root.unmount());
419
+ const { spies, selection } = fixture(HOST);
420
+ const field = document.createElement("input");
421
+ document.body.append(field);
422
+ enterOn(field);
423
+ expect(selection.element.hasAttribute("contenteditable")).toBe(false);
424
+ enterOn(document.body);
425
+ expect(selection.element.hasAttribute("contenteditable")).toBe(true);
426
+ expect(spies.onTextEditingChange.mock.calls).toEqual([[true]]);
427
+ });
428
+
429
+ it("keeps the handles working: drag, resize dots and rotate", () => {
430
+ const { spies, overlay } = fixture(HOST);
431
+ fire(overlay.querySelector(BOX)!, "pointerdown");
432
+ expect(spies.onManualDragStart).toHaveBeenCalledTimes(1);
433
+ fire(overlay.querySelector(BOX)!, "pointerup");
434
+ expect(overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(4);
435
+ const rotate = overlay.querySelector('[aria-label="Rotate selection"]')!;
436
+ fire(rotate, "pointerdown", { clientX: 200, clientY: 250 });
437
+ fire(rotate, "pointermove", { clientX: 300, clientY: 150 });
438
+ fire(rotate, "pointerup", { clientX: 300, clientY: 150 });
439
+ expect(spies.onRotationCommit).toHaveBeenCalledTimes(1);
440
+ });
441
+ });
442
+
443
+ describe("DomEditOverlay onTextEditingChange", () => {
444
+ it("reports true when the caret goes live and false when the edit ends", () => {
445
+ const { spies, selection, overlay } = fixture();
446
+ expect(spies.onTextEditingChange).not.toHaveBeenCalled();
447
+ pressEnter(overlay);
448
+ expect(spies.onTextEditingChange.mock.calls).toEqual([[true]]);
449
+ act(() => {
450
+ selection.element.dispatchEvent(
451
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
452
+ );
453
+ });
454
+ expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
455
+ });
456
+
457
+ it("reports false when the overlay unmounts mid-edit", () => {
458
+ const { spies, overlay } = fixture();
459
+ pressEnter(overlay);
460
+ act(() => root.unmount());
461
+ expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
462
+ });
463
+ });
@@ -989,6 +989,141 @@ describe("AudioFxGroup dynamic carve", () => {
989
989
  expect(onSetAttributeLive.mock.calls.every((c) => c[0] === "data-fx-carve")).toBe(true);
990
990
  expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
991
991
  });
992
+
993
+ describe("on each clip's own clock", () => {
994
+ type Point = { t: number; v: number };
995
+ /** `hz` at `amp` over [from, to) of a `seconds`-long file, silence elsewhere. */
996
+ const tone = (seconds: number, [from, to]: [number, number], hz = 1000, amp = 0.7) =>
997
+ Float32Array.from({ length: SAMPLE_RATE * seconds }, (_, i) => {
998
+ const t = i / SAMPLE_RATE;
999
+ return t >= from && t < to ? amp * Math.sin(2 * Math.PI * hz * t) : 0;
1000
+ });
1001
+
1002
+ /** Each file decodes to its own samples, found by the URL that was fetched. */
1003
+ function stubFiles(files: Record<string, Float32Array>): void {
1004
+ vi.stubGlobal(
1005
+ "fetch",
1006
+ vi.fn(async (url: string) => ({ arrayBuffer: async () => url })),
1007
+ );
1008
+ vi.stubGlobal(
1009
+ "OfflineAudioContext",
1010
+ class {
1011
+ decodeAudioData = async (url: string) => {
1012
+ const data = Object.entries(files).find(([name]) => url.endsWith(name))![1];
1013
+ return { sampleRate: SAMPLE_RATE, getChannelData: () => data };
1014
+ };
1015
+ },
1016
+ );
1017
+ }
1018
+
1019
+ const valueAt = (points: Point[], t: number): number => {
1020
+ const i = points.findIndex((p) => p.t >= t);
1021
+ if (i <= 0) return points.at(i)!.v;
1022
+ const [a, b] = [points[i - 1]!, points[i]!];
1023
+ return a.v + ((b.v - a.v) * (t - a.t)) / (b.t - a.t);
1024
+ };
1025
+
1026
+ /** Carves the bed against `vo` and reads back the lane driving the first node of `type`. */
1027
+ async function carveLane(
1028
+ type: string,
1029
+ voice: Record<string, string>,
1030
+ bed: Record<string, string> = {},
1031
+ strength = 0,
1032
+ ) {
1033
+ const { host, onSetAttributeQuiet } = mount({
1034
+ "fx-chain": carvedChain,
1035
+ "fx-carve": settings(true, { strength, sources: [] }),
1036
+ start: "0",
1037
+ ...bed,
1038
+ });
1039
+ const vo = document.getElementById("vo")!;
1040
+ vo.setAttribute("src", "voice.wav");
1041
+ for (const [name, value] of Object.entries(voice)) vo.setAttribute(`data-${name}`, value);
1042
+ document.getElementById("bed")!.setAttribute("src", "bed.wav");
1043
+ await act(async () => {
1044
+ pickSource(host, "vo");
1045
+ });
1046
+ const calls = onSetAttributeQuiet.mock.calls;
1047
+ const nodes = parseWrite(writeTo(calls, "data-fx-chain")!).nodes as {
1048
+ id: string;
1049
+ type: string;
1050
+ params: { gain: number; frequency: number };
1051
+ }[];
1052
+ const node = nodes.find((n) => n.type === type)!;
1053
+ const lanes = parseWrite(writeTo(calls, "data-automation")!).lanes as {
1054
+ target: string;
1055
+ points: Point[];
1056
+ }[];
1057
+ const points = lanes.find((l) => l.target === `fx.${node.id}.gain`)!.points;
1058
+ return {
1059
+ depth: node.params.gain,
1060
+ frequency: node.params.frequency,
1061
+ points,
1062
+ at: (t: number) => valueAt(points, t),
1063
+ };
1064
+ }
1065
+
1066
+ /** Quiet 1 kHz speech at 0-2 s, loud 4 kHz speech at 4-6 s, in an 8 s file. */
1067
+ const lateSpeech = () => {
1068
+ const after = tone(8, [4, 6], 4000, 0.9);
1069
+ return tone(8, [0, 2], 1000, 0.3).map((v, i) => v + after[i]!);
1070
+ };
1071
+
1072
+ it("ignores speech after the bed has stopped playing", async () => {
1073
+ // The bed file runs 8 s, but the clip plays only its first 3 s.
1074
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(8, [0, 8], 200) });
1075
+ const lane = await carveLane("peaking", { start: "0" }, { duration: "3" });
1076
+ expect(lane.frequency).toBe(1000);
1077
+ });
1078
+
1079
+ it("ignores speech after a bed with no duration runs out, when it ducks", async () => {
1080
+ // No data-duration: the bed plays its 3 s file. Strength 0.05 ducks and keeps one band.
1081
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
1082
+ const lane = await carveLane("peaking", { start: "0" }, {}, 0.05);
1083
+ expect(lane.frequency).toBe(1000);
1084
+ });
1085
+
1086
+ it.each([
1087
+ ["no duration", {}],
1088
+ ["a duration longer than its file", { duration: "6" }],
1089
+ ])("ignores speech after a 3 s bed file with %s, without ducking", async (_, bed) => {
1090
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
1091
+ const lane = await carveLane("peaking", { start: "0" }, bed);
1092
+ expect(lane.frequency).toBe(1000);
1093
+ });
1094
+
1095
+ it("cuts where a trimmed voice speaks, not where its file does", async () => {
1096
+ // File speech 2-3.5 s; the clip plays 2-4 s of it, so the bed hears it at 0-1.5 s.
1097
+ stubFiles({ "voice.wav": tone(6, [2, 3.5]), "bed.wav": tone(8, [0, 8], 200) });
1098
+ const lane = await carveLane("peaking", { start: "0", "media-start": "2", duration: "2" });
1099
+ expect({ speaking: lane.at(0.8) <= lane.depth * 0.5, after: lane.at(3) }).toEqual({
1100
+ speaking: true,
1101
+ after: 0,
1102
+ });
1103
+ });
1104
+
1105
+ it("cuts over half the source length for a voice at 2x", async () => {
1106
+ // File speech 1-3 s at 2x: the bed hears it at 0.5-1.5 s.
1107
+ stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [0, 8], 200) });
1108
+ const lane = await carveLane("peaking", { start: "0", "playback-rate": "2", duration: "2" });
1109
+ expect({
1110
+ speaking: lane.at(0.8) <= lane.depth * 0.5 && lane.at(1.3) <= lane.depth * 0.5,
1111
+ after: lane.at(2.5),
1112
+ }).toEqual({ speaking: true, after: 0 });
1113
+ });
1114
+
1115
+ it("ducks against the part of the bed that plays", async () => {
1116
+ // The bed file is silent for 4 s and loud after; the clip starts 4 s in, so it is loud under the voice.
1117
+ stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [4, 8], 200, 0.9) });
1118
+ const lane = await carveLane(
1119
+ "gain",
1120
+ { start: "0" },
1121
+ { "media-start": "4", duration: "4" },
1122
+ 1,
1123
+ );
1124
+ expect(Math.min(...lane.points.map((p) => p.v))).toBeLessThan(0);
1125
+ });
1126
+ });
992
1127
  });
993
1128
 
994
1129
  describe("AudioFxGroup successive edits", () => {
@@ -33,7 +33,8 @@ import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAu
33
33
  import { trackCarveChanged } from "./audioFxTelemetry.js";
34
34
  import type { DomEditSelection } from "./domEditingTypes";
35
35
  import { usePlayerStore } from "../../player";
36
- import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
36
+ import { carveLanes, measureCarve, mintCarveNodes, type CarveClip } from "./useFxCarveNodes.js";
37
+ import { readClipClock } from "./clipAudioClock.js";
37
38
  import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
38
39
  import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
39
40
 
@@ -93,11 +94,8 @@ function carveNeedsReanalysis(
93
94
  * what lets the src and the start be non-null by construction downstream
94
95
  * instead of by assertion.
95
96
  */
96
- function resolveCarveVoices(
97
- doc: Document,
98
- sources: readonly string[],
99
- ): { src: string; start: string | null }[] {
100
- const voices: { src: string; start: string | null }[] = [];
97
+ function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
98
+ const voices: CarveClip[] = [];
101
99
  for (const id of sources) {
102
100
  const el = doc.getElementById(id);
103
101
  // By tag name, not `instanceof HTMLAudioElement`: these elements belong to
@@ -106,7 +104,8 @@ function resolveCarveVoices(
106
104
  if (el?.tagName !== "AUDIO") continue;
107
105
  const src = el.getAttribute("src");
108
106
  if (!src) continue;
109
- voices.push({ src, start: el.getAttribute("data-start") });
107
+ const clock = readClipClock((name) => el.getAttribute(name));
108
+ voices.push({ src, start: el.getAttribute("data-start"), clock });
110
109
  }
111
110
  return voices;
112
111
  }
@@ -363,14 +362,11 @@ export function useFxCarve(
363
362
  if (voices.length === 0) return;
364
363
  setAnalysing(true);
365
364
  try {
366
- const bedSrc = element.element?.getAttribute("src");
367
- const measured = await measureCarve(
368
- doc,
369
- voices,
370
- active.strength,
371
- element.dataAttributes?.["start"],
372
- bedSrc,
373
- );
365
+ const measured = await measureCarve(doc, voices, active.strength, {
366
+ src: element.element?.getAttribute("src"),
367
+ start: element.dataAttributes?.["start"],
368
+ clock: readClipClock((name) => element.dataAttributes?.[name.slice(5)]),
369
+ });
374
370
  if (!measured) return;
375
371
  await persistMeasuredCarve(measured);
376
372
  } catch {