@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,54 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { useTimelineEditRefusal } from "./timelineEditPermission";
4
+
5
+ type GuardedTimelineHandler = (...args: never[]) => Promise<unknown>;
6
+ type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
7
+ type GuardedTimelineRefusal = (reason: string, ...args: never[]) => unknown;
8
+
9
+ interface GuardedTimelineEntry {
10
+ resolveTargets: GuardedTimelineResolver;
11
+ onRefused?: GuardedTimelineRefusal;
12
+ wrapped: GuardedTimelineHandler;
13
+ }
14
+
15
+ export function useTimelineEditGuard(
16
+ canEdit: Parameters<typeof useTimelineEditRefusal>[0],
17
+ showToast: Parameters<typeof useTimelineEditRefusal>[1],
18
+ ) {
19
+ const refuseEdit = useTimelineEditRefusal(canEdit, showToast);
20
+ const refuseEditRef = useRef(refuseEdit);
21
+ refuseEditRef.current = refuseEdit;
22
+ const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
23
+ // Refuses (no call, no write, no history entry) when any target is
24
+ // blocked; otherwise runs fn as before. Cached by fn identity — like
25
+ // track() — so a fresh closure here doesn't defeat track's own cache.
26
+ const guard = useCallback(
27
+ <H extends (...args: never[]) => Promise<unknown>>(
28
+ resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
29
+ fn: H,
30
+ onRefused?: (reason: string, ...args: Parameters<H>) => Awaited<ReturnType<H>>,
31
+ ): H => {
32
+ const key = fn as unknown as GuardedTimelineHandler;
33
+ const cached = guardedRef.current.get(key);
34
+ if (cached) {
35
+ cached.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
36
+ cached.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
37
+ return cached.wrapped as H;
38
+ }
39
+ const entry = {} as GuardedTimelineEntry;
40
+ entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
41
+ entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
42
+ entry.wrapped = ((...args: Parameters<H>) => {
43
+ const reason = refuseEditRef.current(entry.resolveTargets(...(args as never[])));
44
+ if (reason !== null)
45
+ return Promise.resolve(entry.onRefused?.(reason, ...(args as never[])));
46
+ return fn(...args);
47
+ }) as H as unknown as GuardedTimelineHandler;
48
+ guardedRef.current.set(key, entry);
49
+ return entry.wrapped as H;
50
+ },
51
+ [],
52
+ );
53
+ return guard;
54
+ }
@@ -26,7 +26,14 @@ import {
26
26
  import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
27
27
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
28
28
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
29
+ import { useSetElementsAttribute } from "./timelineElementsAttribute";
29
30
  import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
31
+ import { useTimelineEditGuard } from "./useTimelineEditGuard";
32
+ import {
33
+ linkEditTargets,
34
+ useTimelineLinkEditing,
35
+ withLinkPartners,
36
+ } from "./useTimelineLinkEditing";
30
37
  import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
31
38
  import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
32
39
  import {
@@ -35,26 +42,12 @@ import {
35
42
  } from "./timelineTrackVisibility";
36
43
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
37
44
  import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
38
- import {
39
- useTimelineEditGate,
40
- useTimelineEditRefusal,
41
- type TimelineEditOutcome,
42
- } from "./timelineEditPermission";
45
+ import { useTimelineEditGate, type TimelineEditOutcome } from "./timelineEditPermission";
43
46
  import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
44
47
  import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
45
48
  import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
46
49
  import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
47
50
 
48
- type GuardedTimelineHandler = (...args: never[]) => Promise<unknown>;
49
- type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
50
- type GuardedTimelineRefusal = (reason: string, ...args: never[]) => unknown;
51
-
52
- interface GuardedTimelineEntry {
53
- resolveTargets: GuardedTimelineResolver;
54
- onRefused?: GuardedTimelineRefusal;
55
- wrapped: GuardedTimelineHandler;
56
- }
57
-
58
51
  export function useTimelineEditing({
59
52
  projectId,
60
53
  activeCompPath,
@@ -80,40 +73,7 @@ export function useTimelineEditing({
80
73
  const editQueueRef = useRef(Promise.resolve());
81
74
  const track = useTrackPendingTimelineEdit();
82
75
  const checkEditable = useTimelineEditGate(canEdit, showToast);
83
- const refuseEdit = useTimelineEditRefusal(canEdit, showToast);
84
- const refuseEditRef = useRef(refuseEdit);
85
- refuseEditRef.current = refuseEdit;
86
- const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
87
- // Refuses (no call, no write, no history entry) when any target is
88
- // blocked; otherwise runs fn as before. Cached by fn identity — like
89
- // track() — so a fresh closure here doesn't defeat track's own cache.
90
- const guard = useCallback(
91
- <H extends (...args: never[]) => Promise<unknown>>(
92
- resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
93
- fn: H,
94
- onRefused?: (reason: string, ...args: Parameters<H>) => Awaited<ReturnType<H>>,
95
- ): H => {
96
- const key = fn as unknown as GuardedTimelineHandler;
97
- const cached = guardedRef.current.get(key);
98
- if (cached) {
99
- cached.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
100
- cached.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
101
- return cached.wrapped as H;
102
- }
103
- const entry = {} as GuardedTimelineEntry;
104
- entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
105
- entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
106
- entry.wrapped = ((...args: Parameters<H>) => {
107
- const reason = refuseEditRef.current(entry.resolveTargets(...(args as never[])));
108
- if (reason !== null)
109
- return Promise.resolve(entry.onRefused?.(reason, ...(args as never[])));
110
- return fn(...args);
111
- }) as H as unknown as GuardedTimelineHandler;
112
- guardedRef.current.set(key, entry);
113
- return entry.wrapped as H;
114
- },
115
- [],
116
- );
76
+ const guard = useTimelineEditGuard(canEdit, showToast);
117
77
 
118
78
  const enqueueEdit = useCallback(
119
79
  (
@@ -252,7 +212,7 @@ export function useTimelineEditing({
252
212
  return reorderDone
253
213
  .then(() => {
254
214
  // SDK setTiming writes start only; a lane change needs the fallback's track patch.
255
- if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
215
+ if (sdkSession && element.hfId && !element.link && !needsExtension && !trackChanged) {
256
216
  return sdkTimingPersist(
257
217
  element.hfId,
258
218
  targetPath,
@@ -359,7 +319,7 @@ export function useTimelineEditing({
359
319
  finishResizeGsapSync(),
360
320
  );
361
321
  const persistDone =
362
- sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
322
+ sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension
363
323
  ? sdkTimingPersist(
364
324
  element.hfId,
365
325
  targetPath,
@@ -450,6 +410,17 @@ export function useTimelineEditing({
450
410
  isRecordingRef,
451
411
  });
452
412
 
413
+ const setElementsAttribute = useSetElementsAttribute({
414
+ projectIdRef,
415
+ activeCompPath,
416
+ showToast,
417
+ writeProjectFile,
418
+ recordEdit,
419
+ previewIframeRef,
420
+ pendingTimelineEditPathRef,
421
+ isRecordingRef,
422
+ });
423
+
453
424
  const setAudioGroupAttribute = useSetAudioGroupAttribute({
454
425
  projectIdRef,
455
426
  activeCompPath,
@@ -461,7 +432,7 @@ export function useTimelineEditing({
461
432
  isRecordingRef,
462
433
  });
463
434
 
464
- const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
435
+ const { handleTimelineElementsDelete } = useTimelineDeleteOps({
465
436
  projectIdRef,
466
437
  activeCompPath,
467
438
  timelineElements,
@@ -491,6 +462,20 @@ export function useTimelineEditing({
491
462
  checkEditable,
492
463
  });
493
464
 
465
+ const linkEditing = useTimelineLinkEditing({
466
+ projectIdRef,
467
+ activeCompPath,
468
+ editQueueRef,
469
+ pendingTimelineEditPathRef,
470
+ isRecordingRef,
471
+ showToast,
472
+ writeProjectFile,
473
+ recordEdit,
474
+ reloadPreview,
475
+ forceReloadSdkSession,
476
+ handleTimelineElementsDelete,
477
+ });
478
+
494
479
  const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
495
480
  const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
496
481
  projectId,
@@ -526,7 +511,9 @@ export function useTimelineEditing({
526
511
  // Every write-handler is tracked here, the one place all hand edits
527
512
  // converge, so undo never races a write; canEdit gates the same point.
528
513
  // Coverage boundary: see the PR body, not every kind resolves an element.
529
- const trackedRazorSplit = track(guard((element) => [element], handleRazorSplit));
514
+ const trackedRazorSplit = track(
515
+ guard((element) => withLinkPartners([element]), handleRazorSplit),
516
+ );
530
517
  return {
531
518
  handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
532
519
  handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
@@ -557,6 +544,7 @@ export function useTimelineEditing({
557
544
  },
558
545
  setElementFxAttribute: {
559
546
  ...setElementFxAttribute,
547
+ setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
560
548
  setQuiet: track(
561
549
  guard(
562
550
  (element) => [element],
@@ -568,10 +556,16 @@ export function useTimelineEditing({
568
556
  ),
569
557
  ),
570
558
  },
571
- handleTimelineElementDelete: track(guard((element) => [element], handleTimelineElementDelete)),
559
+ handleTimelineElementDelete: track(
560
+ guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
561
+ ),
572
562
  handleTimelineElementsDelete: track(
573
- guard((elements) => elements, handleTimelineElementsDelete),
563
+ guard(withLinkPartners, linkEditing.handleLinkedElementsDelete),
564
+ ),
565
+ handleTimelineElementDeleteOnly: track(
566
+ guard((element) => [element], linkEditing.handleDeleteElementOnly),
574
567
  ),
568
+ handleLinkEdit: track(guard(linkEditTargets, linkEditing.handleLinkEdit)),
575
569
  handleTimelineElementSplit: trackedRazorSplit,
576
570
  handleRazorSplit: trackedRazorSplit,
577
571
  handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
@@ -0,0 +1,124 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { usePlayerStore, type TimelineElement } from "../player";
6
+ import { useTimelineLinkEditing, withLinkPartners } from "./useTimelineLinkEditing";
7
+ import { useLinkedClipPreferences } from "../utils/linkedClipPreferences";
8
+
9
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const clip = (id: string, tag: string, link?: string): TimelineElement => ({
12
+ id,
13
+ domId: id,
14
+ tag,
15
+ start: 0,
16
+ duration: 4,
17
+ track: 0,
18
+ ...(link ? { link } : {}),
19
+ });
20
+ const video = clip("talk", "video", "lk-1");
21
+ const audio = clip("talk-audio", "audio", "lk-1");
22
+ const title = clip("title", "div");
23
+
24
+ const SOURCE =
25
+ '<div><video id="talk" muted data-link="lk-1"></video><audio id="talk-audio" data-link="lk-1"></audio></div>';
26
+
27
+ async function renderLinkEditing(handleTimelineElementsDelete = vi.fn()) {
28
+ usePlayerStore.getState().setElements([video, audio, title]);
29
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
30
+ const recordEdit = vi.fn().mockResolvedValue(undefined);
31
+ let api: ReturnType<typeof useTimelineLinkEditing> | undefined;
32
+ function Harness() {
33
+ api = useTimelineLinkEditing({
34
+ projectIdRef: { current: "p1" },
35
+ activeCompPath: "index.html",
36
+ editQueueRef: { current: Promise.resolve() },
37
+ pendingTimelineEditPathRef: { current: new Set() },
38
+ showToast: vi.fn(),
39
+ writeProjectFile,
40
+ recordEdit,
41
+ reloadPreview: vi.fn(),
42
+ handleTimelineElementsDelete,
43
+ });
44
+ return null;
45
+ }
46
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
47
+ await act(async () => root.render(React.createElement(Harness)));
48
+ if (!api) throw new Error("hook did not render");
49
+ return { api, writeProjectFile, recordEdit, unmount: () => act(() => root.unmount()) };
50
+ }
51
+
52
+ describe("withLinkPartners", () => {
53
+ it("adds partners after the selection, once", () => {
54
+ usePlayerStore.getState().setElements([video, audio, title]);
55
+ expect(withLinkPartners([video, title]).map((el) => el.id)).toEqual([
56
+ "talk",
57
+ "title",
58
+ "talk-audio",
59
+ ]);
60
+ expect(withLinkPartners([video, audio])).toHaveLength(2);
61
+ });
62
+ });
63
+
64
+ describe("useTimelineLinkEditing", () => {
65
+ afterEach(() => vi.restoreAllMocks());
66
+
67
+ it("deletes a linked clip with its partner", async () => {
68
+ const del = vi.fn().mockResolvedValue(undefined);
69
+ const { api, unmount } = await renderLinkEditing(del);
70
+ await act(async () => api.handleLinkedElementDelete(video));
71
+ expect(del.mock.calls[0]?.[0].map((el: TimelineElement) => el.id)).toEqual([
72
+ "talk",
73
+ "talk-audio",
74
+ ]);
75
+ unmount();
76
+ });
77
+
78
+ it("with Linked Selection off, delete removes the clip alone and unlinks the survivor", async () => {
79
+ const del = vi.fn().mockResolvedValue(undefined);
80
+ const { api, unmount } = await renderLinkEditing(del);
81
+ useLinkedClipPreferences.getState().setLinkedSelection(false);
82
+ try {
83
+ expect(withLinkPartners([video])).toEqual([video]);
84
+ await act(async () => api.handleLinkedElementDelete(video));
85
+ expect(del).toHaveBeenCalledWith([video], [audio]);
86
+ } finally {
87
+ useLinkedClipPreferences.getState().setLinkedSelection(true);
88
+ }
89
+ unmount();
90
+ });
91
+
92
+ it("delete-only removes one member and unlinks the survivor", async () => {
93
+ const del = vi.fn().mockResolvedValue(undefined);
94
+ const { api, unmount } = await renderLinkEditing(del);
95
+ await act(async () => api.handleDeleteElementOnly(video));
96
+ expect(del).toHaveBeenCalledWith([video], [audio]);
97
+ unmount();
98
+ });
99
+
100
+ it("unlink writes one undo step removing data-link from both", async () => {
101
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
102
+ new Response(JSON.stringify({ content: SOURCE }), { status: 200 }),
103
+ );
104
+ const { api, writeProjectFile, recordEdit, unmount } = await renderLinkEditing();
105
+ await act(async () => api.handleLinkEdit({ kind: "unlink", elements: [video, audio] }));
106
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
107
+ expect(writeProjectFile.mock.calls[0]?.[1]).not.toContain("data-link");
108
+ expect(recordEdit).toHaveBeenCalledTimes(1);
109
+ expect(recordEdit.mock.calls[0]?.[0].label).toBe("Unlink clips");
110
+ unmount();
111
+ });
112
+
113
+ it("unlink leaves nothing selected, so a later trim moves only the grabbed clip", async () => {
114
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
115
+ new Response(JSON.stringify({ content: SOURCE }), { status: 200 }),
116
+ );
117
+ const { api, unmount } = await renderLinkEditing();
118
+ usePlayerStore.getState().setSelection(["talk", "talk-audio"], "talk");
119
+ await act(async () => api.handleLinkEdit({ kind: "unlink", elements: [video, audio] }));
120
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
121
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
122
+ unmount();
123
+ });
124
+ });
@@ -0,0 +1,158 @@
1
+ import { useCallback, type MutableRefObject } from "react";
2
+ import { usePlayerStore, type TimelineElement } from "../player";
3
+ import type { TimelineLinkEdit } from "../player/components/timelineCallbacks";
4
+ import { expandToLinkedMembers } from "../player/components/audioClipLink";
5
+ import { isLinkedSelectionOn } from "../utils/linkedClipPreferences";
6
+ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
7
+ import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
8
+ import { readFileContent } from "./timelineEditingHelpers";
9
+ import { clipsToUnlink, planLinkEdit } from "./timelineLinkEditPlan";
10
+
11
+ interface UseTimelineLinkEditingOptions {
12
+ projectIdRef: MutableRefObject<string | null>;
13
+ activeCompPath: string | null;
14
+ editQueueRef: MutableRefObject<Promise<void>>;
15
+ pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
16
+ isRecordingRef?: MutableRefObject<boolean>;
17
+ showToast: (message: string, tone?: "error" | "info") => void;
18
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
19
+ recordEdit: (input: RecordEditInput) => Promise<void>;
20
+ reloadPreview: () => void;
21
+ forceReloadSdkSession?: () => void;
22
+ handleTimelineElementsDelete: (
23
+ selection: TimelineElement[],
24
+ alsoUnlink?: readonly TimelineElement[],
25
+ ) => Promise<void>;
26
+ }
27
+
28
+ const keyOf = (el: TimelineElement) => el.key ?? el.id;
29
+
30
+ /** Every clip a link edit writes, for the edit-permission gate. */
31
+ export function linkEditTargets(edit: TimelineLinkEdit): TimelineElement[] {
32
+ switch (edit.kind) {
33
+ case "unlink":
34
+ case "link":
35
+ return [...edit.elements];
36
+ case "detach":
37
+ case "move-into-sync":
38
+ case "slip-into-sync":
39
+ return [edit.element];
40
+ case "merge":
41
+ return [edit.video, edit.audio];
42
+ }
43
+ }
44
+
45
+ export function withLinkPartners(selection: readonly TimelineElement[]): TimelineElement[] {
46
+ const elements = usePlayerStore.getState().elements;
47
+ const keys = expandToLinkedMembers(selection.map(keyOf), elements, isLinkedSelectionOn());
48
+ const known = new Set(selection.map(keyOf));
49
+ return [...selection, ...elements.filter((el) => keys.has(keyOf(el)) && !known.has(keyOf(el)))];
50
+ }
51
+
52
+ function clearSelectionOnUnlink(edit: TimelineLinkEdit): void {
53
+ if (edit.kind === "unlink") usePlayerStore.getState().clearSelection();
54
+ }
55
+
56
+ export function useTimelineLinkEditing({
57
+ projectIdRef,
58
+ activeCompPath,
59
+ editQueueRef,
60
+ pendingTimelineEditPathRef,
61
+ isRecordingRef,
62
+ showToast,
63
+ writeProjectFile,
64
+ recordEdit,
65
+ reloadPreview,
66
+ forceReloadSdkSession,
67
+ handleTimelineElementsDelete,
68
+ }: UseTimelineLinkEditingOptions) {
69
+ const handleLinkEdit = useCallback(
70
+ async (edit: TimelineLinkEdit) => {
71
+ if (isRecordingRef?.current) {
72
+ showToast("Cannot edit timeline while recording", "error");
73
+ return;
74
+ }
75
+ const pid = projectIdRef.current;
76
+ const plan = planLinkEdit(edit, usePlayerStore.getState().elements);
77
+ if (!pid || !plan) return;
78
+ clearSelectionOnUnlink(edit);
79
+ const path = plan.anchor.sourceFile || activeCompPath || "index.html";
80
+ pendingTimelineEditPathRef.current.add(path);
81
+ const queued = editQueueRef.current.then(() =>
82
+ saveProjectFilesWithHistory({
83
+ projectId: pid,
84
+ label: plan.label,
85
+ files: {
86
+ [path]: (current) => {
87
+ const next = plan.transform(current);
88
+ if (next === null || next === current) {
89
+ throw new Error(`Couldn't ${plan.label.toLowerCase()}`);
90
+ }
91
+ return next;
92
+ },
93
+ },
94
+ readFile: (filePath) => readFileContent(pid, filePath),
95
+ writeFile: writeProjectFile,
96
+ recordEdit,
97
+ }),
98
+ );
99
+ editQueueRef.current = queued.then(
100
+ () => undefined,
101
+ () => undefined,
102
+ );
103
+ try {
104
+ await queued;
105
+ forceReloadSdkSession?.();
106
+ reloadPreview();
107
+ } catch (error) {
108
+ showToast(getStudioSaveErrorMessage(error), "error");
109
+ }
110
+ },
111
+ [
112
+ activeCompPath,
113
+ editQueueRef,
114
+ forceReloadSdkSession,
115
+ isRecordingRef,
116
+ pendingTimelineEditPathRef,
117
+ projectIdRef,
118
+ recordEdit,
119
+ reloadPreview,
120
+ showToast,
121
+ writeProjectFile,
122
+ ],
123
+ );
124
+
125
+ const handleLinkedElementsDelete = useCallback(
126
+ (selection: TimelineElement[]) => {
127
+ if (isLinkedSelectionOn()) return handleTimelineElementsDelete(withLinkPartners(selection));
128
+ const removed = new Set(selection.map(keyOf));
129
+ const orphans = clipsToUnlink(selection, usePlayerStore.getState().elements).filter(
130
+ (el) => !removed.has(keyOf(el)),
131
+ );
132
+ return handleTimelineElementsDelete(selection, orphans);
133
+ },
134
+ [handleTimelineElementsDelete],
135
+ );
136
+
137
+ const handleLinkedElementDelete = useCallback(
138
+ (element: TimelineElement) => handleLinkedElementsDelete([element]),
139
+ [handleLinkedElementsDelete],
140
+ );
141
+
142
+ const handleDeleteElementOnly = useCallback(
143
+ (element: TimelineElement) => {
144
+ const orphans = clipsToUnlink([element], usePlayerStore.getState().elements).filter(
145
+ (el) => keyOf(el) !== keyOf(element),
146
+ );
147
+ return handleTimelineElementsDelete([element], orphans);
148
+ },
149
+ [handleTimelineElementsDelete],
150
+ );
151
+
152
+ return {
153
+ handleLinkEdit,
154
+ handleLinkedElementsDelete,
155
+ handleLinkedElementDelete,
156
+ handleDeleteElementOnly,
157
+ };
158
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,11 @@ export {
31
31
  liveTime,
32
32
  formatTime,
33
33
  } from "./player";
34
+ export { usePreviewIframeStore } from "./player/store/previewIframeStore";
35
+ export {
36
+ openAudioGainDialog,
37
+ useAudioGainDialogStore,
38
+ } from "./player/components/audioGainDialogStore";
34
39
  export type {
35
40
  PlayerHandle,
36
41
  PlayerHandleElement,
@@ -0,0 +1,133 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { audioGainToDb } from "@hyperframes/core/audio-gain";
5
+ import { createHappyDomRootHarness } from "./testRootHarness";
6
+ import { AudioGainDialog } from "./AudioGainDialog";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import type { TimelineElement } from "../store/timelineElement";
9
+
10
+ const onNotice = vi.fn();
11
+ const setQuiet = vi.fn(async () => {});
12
+ const setMany = vi.fn(
13
+ async (_edits: ReadonlyArray<{ value: string | null }>, _attr: string, _label: string) => {},
14
+ );
15
+
16
+ vi.mock("../../contexts/StudioContext", () => ({
17
+ useStudioShellContextOptional: () => null,
18
+ }));
19
+ vi.mock("../../contexts/TimelineEditContext", () => ({
20
+ useTimelineEditContextOptional: () => ({
21
+ onSetElementAttributeQuiet: setQuiet,
22
+ onSetElementsAttributeQuiet: setMany,
23
+ onNotice,
24
+ }),
25
+ }));
26
+
27
+ const harness = createHappyDomRootHarness();
28
+ const clip = (id: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
29
+ id,
30
+ tag: "audio",
31
+ start: 0,
32
+ duration: 2,
33
+ track: 0,
34
+ src: `${id}.wav`,
35
+ ...extra,
36
+ });
37
+
38
+ function stubPeaks(peaks: Record<string, number[]>) {
39
+ vi.stubGlobal(
40
+ "fetch",
41
+ vi.fn(async (url: string) => {
42
+ const name = Object.keys(peaks).find((key) => String(url).includes(`/peaks/${key}.wav`));
43
+ return name
44
+ ? Response.json({ binSeconds: 1, bins: peaks[name] })
45
+ : new Response("", { status: 404 });
46
+ }),
47
+ );
48
+ }
49
+
50
+ function open(elements: TimelineElement[]) {
51
+ const host = document.createElement("div");
52
+ document.body.appendChild(host);
53
+ act(() => harness.mount(host).render(<AudioGainDialog elements={elements} onClose={() => {}} />));
54
+ }
55
+
56
+ const row = (text: string) =>
57
+ [...document.querySelectorAll("label")].find((label) => label.textContent?.startsWith(text));
58
+ const choose = (text: string) =>
59
+ act(() => row(text)?.querySelector<HTMLInputElement>("input[type=radio]")?.click());
60
+ function typeDb(text: string, value: string) {
61
+ const input = row(text)?.querySelector<HTMLInputElement>("input[type=number]");
62
+ if (!input) throw new Error(`no field for ${text}`);
63
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
64
+ act(() => {
65
+ setter?.call(input, value);
66
+ input.dispatchEvent(new Event("input", { bubbles: true }));
67
+ });
68
+ }
69
+ const ok = () => [...document.querySelectorAll("button")].find((b) => b.textContent === "OK");
70
+ const peakText = () => document.querySelector("[data-testid='audio-gain-peak']")?.textContent;
71
+
72
+ beforeEach(() => {
73
+ usePlayerStore.getState().beginTimelineSession("p1");
74
+ vi.clearAllMocks();
75
+ });
76
+ afterEach(() => vi.unstubAllGlobals());
77
+
78
+ describe("AudioGainDialog", () => {
79
+ it("shows the selection's peak amplitude at the clips' current gain", async () => {
80
+ stubPeaks({ pk1: [0.25, 0.5] });
81
+ open([clip("pk1", { volume: 2 })]);
82
+ await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: 0.0 dB"));
83
+ });
84
+
85
+ it("Set Gain writes every selected clip in one undo step", async () => {
86
+ stubPeaks({});
87
+ const a = clip("set-a");
88
+ const b = clip("set-b", { volume: 0.5 });
89
+ open([a, b]);
90
+ choose("Set Gain to");
91
+ typeDb("Set Gain to", "-6");
92
+ await act(async () => ok()?.click());
93
+ expect(setMany).toHaveBeenCalledTimes(1);
94
+ const [edits, attr, label] = setMany.mock.calls[0] ?? [];
95
+ expect(attr).toBe("data-volume");
96
+ expect(label).toBe("Audio Gain");
97
+ expect(edits?.map((e) => audioGainToDb(Number(e.value)))).toEqual([
98
+ expect.closeTo(-6, 3),
99
+ expect.closeTo(-6, 3),
100
+ ]);
101
+ expect(setQuiet).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("Normalize All Peaks brings each clip's own peak to the target", async () => {
105
+ stubPeaks({ na1: [0.5], na2: [0.25] });
106
+ open([clip("na1"), clip("na2")]);
107
+ await vi.waitFor(() => expect(peakText()).not.toContain("measuring"));
108
+ choose("Normalize All Peaks to");
109
+ typeDb("Normalize All Peaks to", "-3");
110
+ await act(async () => ok()?.click());
111
+ const [edits] = setMany.mock.calls[0] ?? [];
112
+ const gains = edits?.map((e) => Number(e.value)) ?? [];
113
+ expect(audioGainToDb(0.5 * (gains[0] ?? 0))).toBeCloseTo(-3, 3);
114
+ expect(audioGainToDb(0.25 * (gains[1] ?? 0))).toBeCloseTo(-3, 3);
115
+ });
116
+
117
+ it("refuses a peak target above 0 dB", async () => {
118
+ stubPeaks({ over: [0.5] });
119
+ open([clip("over")]);
120
+ await vi.waitFor(() => expect(peakText()).not.toContain("measuring"));
121
+ choose("Normalize Max Peak to");
122
+ typeDb("Normalize Max Peak to", "2");
123
+ expect(ok()?.disabled).toBe(true);
124
+ });
125
+
126
+ it("disables the peak options when the clip cannot be measured", async () => {
127
+ stubPeaks({});
128
+ open([clip("unmeasured")]);
129
+ await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: —"));
130
+ expect(row("Normalize Max Peak to")?.querySelector("input")?.disabled).toBe(true);
131
+ expect(row("Adjust Gain by")?.querySelector("input")?.disabled).toBe(false);
132
+ });
133
+ });