@hyperframes/studio 0.8.107 → 0.8.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/chunk-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -27,6 +27,12 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
27
27
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
28
28
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
29
29
  import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
30
+ import { useTimelineEditGuard } from "./useTimelineEditGuard";
31
+ import {
32
+ linkEditTargets,
33
+ useTimelineLinkEditing,
34
+ withLinkPartners,
35
+ } from "./useTimelineLinkEditing";
30
36
  import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
31
37
  import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
32
38
  import {
@@ -35,26 +41,12 @@ import {
35
41
  } from "./timelineTrackVisibility";
36
42
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
37
43
  import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
38
- import {
39
- useTimelineEditGate,
40
- useTimelineEditRefusal,
41
- type TimelineEditOutcome,
42
- } from "./timelineEditPermission";
44
+ import { useTimelineEditGate, type TimelineEditOutcome } from "./timelineEditPermission";
43
45
  import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
44
46
  import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
45
47
  import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
46
48
  import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
47
49
 
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
50
  export function useTimelineEditing({
59
51
  projectId,
60
52
  activeCompPath,
@@ -80,40 +72,7 @@ export function useTimelineEditing({
80
72
  const editQueueRef = useRef(Promise.resolve());
81
73
  const track = useTrackPendingTimelineEdit();
82
74
  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
- );
75
+ const guard = useTimelineEditGuard(canEdit, showToast);
117
76
 
118
77
  const enqueueEdit = useCallback(
119
78
  (
@@ -252,7 +211,7 @@ export function useTimelineEditing({
252
211
  return reorderDone
253
212
  .then(() => {
254
213
  // SDK setTiming writes start only; a lane change needs the fallback's track patch.
255
- if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
214
+ if (sdkSession && element.hfId && !element.link && !needsExtension && !trackChanged) {
256
215
  return sdkTimingPersist(
257
216
  element.hfId,
258
217
  targetPath,
@@ -359,7 +318,7 @@ export function useTimelineEditing({
359
318
  finishResizeGsapSync(),
360
319
  );
361
320
  const persistDone =
362
- sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
321
+ sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension
363
322
  ? sdkTimingPersist(
364
323
  element.hfId,
365
324
  targetPath,
@@ -461,7 +420,7 @@ export function useTimelineEditing({
461
420
  isRecordingRef,
462
421
  });
463
422
 
464
- const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
423
+ const { handleTimelineElementsDelete } = useTimelineDeleteOps({
465
424
  projectIdRef,
466
425
  activeCompPath,
467
426
  timelineElements,
@@ -491,9 +450,22 @@ export function useTimelineEditing({
491
450
  checkEditable,
492
451
  });
493
452
 
494
- const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
453
+ const linkEditing = useTimelineLinkEditing({
454
+ projectIdRef,
455
+ activeCompPath,
456
+ editQueueRef,
457
+ pendingTimelineEditPathRef,
458
+ isRecordingRef,
459
+ showToast,
460
+ writeProjectFile,
461
+ recordEdit,
462
+ reloadPreview,
463
+ forceReloadSdkSession,
464
+ handleTimelineElementsDelete,
465
+ });
495
466
 
496
- const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
467
+ const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
468
+ const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
497
469
  projectId,
498
470
  activeCompPath,
499
471
  showToast,
@@ -527,7 +499,9 @@ export function useTimelineEditing({
527
499
  // Every write-handler is tracked here, the one place all hand edits
528
500
  // converge, so undo never races a write; canEdit gates the same point.
529
501
  // Coverage boundary: see the PR body, not every kind resolves an element.
530
- const trackedRazorSplit = track(guard((element) => [element], handleRazorSplit));
502
+ const trackedRazorSplit = track(
503
+ guard((element) => withLinkPartners([element]), handleRazorSplit),
504
+ );
531
505
  return {
532
506
  handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
533
507
  handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
@@ -569,12 +543,19 @@ export function useTimelineEditing({
569
543
  ),
570
544
  ),
571
545
  },
572
- handleTimelineElementDelete: track(guard((element) => [element], handleTimelineElementDelete)),
546
+ handleTimelineElementDelete: track(
547
+ guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
548
+ ),
573
549
  handleTimelineElementsDelete: track(
574
- guard((elements) => elements, handleTimelineElementsDelete),
550
+ guard(withLinkPartners, linkEditing.handleLinkedElementsDelete),
551
+ ),
552
+ handleTimelineElementDeleteOnly: track(
553
+ guard((element) => [element], linkEditing.handleDeleteElementOnly),
575
554
  ),
555
+ handleLinkEdit: track(guard(linkEditTargets, linkEditing.handleLinkEdit)),
576
556
  handleTimelineElementSplit: trackedRazorSplit,
577
557
  handleRazorSplit: trackedRazorSplit,
558
+ handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
578
559
  // Same selection the handler itself splits (useRazorSplit.ts).
579
560
  handleRazorSplitAll: track(
580
561
  guard(
@@ -0,0 +1,112 @@
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
+ });
@@ -0,0 +1,153 @@
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
+ export function useTimelineLinkEditing({
53
+ projectIdRef,
54
+ activeCompPath,
55
+ editQueueRef,
56
+ pendingTimelineEditPathRef,
57
+ isRecordingRef,
58
+ showToast,
59
+ writeProjectFile,
60
+ recordEdit,
61
+ reloadPreview,
62
+ forceReloadSdkSession,
63
+ handleTimelineElementsDelete,
64
+ }: UseTimelineLinkEditingOptions) {
65
+ const handleLinkEdit = useCallback(
66
+ async (edit: TimelineLinkEdit) => {
67
+ if (isRecordingRef?.current) {
68
+ showToast("Cannot edit timeline while recording", "error");
69
+ return;
70
+ }
71
+ const pid = projectIdRef.current;
72
+ const plan = planLinkEdit(edit, usePlayerStore.getState().elements);
73
+ if (!pid || !plan) return;
74
+ const path = plan.anchor.sourceFile || activeCompPath || "index.html";
75
+ pendingTimelineEditPathRef.current.add(path);
76
+ const queued = editQueueRef.current.then(() =>
77
+ saveProjectFilesWithHistory({
78
+ projectId: pid,
79
+ label: plan.label,
80
+ files: {
81
+ [path]: (current) => {
82
+ const next = plan.transform(current);
83
+ if (next === null || next === current) {
84
+ throw new Error(`Couldn't ${plan.label.toLowerCase()}`);
85
+ }
86
+ return next;
87
+ },
88
+ },
89
+ readFile: (filePath) => readFileContent(pid, filePath),
90
+ writeFile: writeProjectFile,
91
+ recordEdit,
92
+ }),
93
+ );
94
+ editQueueRef.current = queued.then(
95
+ () => undefined,
96
+ () => undefined,
97
+ );
98
+ try {
99
+ await queued;
100
+ forceReloadSdkSession?.();
101
+ reloadPreview();
102
+ } catch (error) {
103
+ showToast(getStudioSaveErrorMessage(error), "error");
104
+ }
105
+ },
106
+ [
107
+ activeCompPath,
108
+ editQueueRef,
109
+ forceReloadSdkSession,
110
+ isRecordingRef,
111
+ pendingTimelineEditPathRef,
112
+ projectIdRef,
113
+ recordEdit,
114
+ reloadPreview,
115
+ showToast,
116
+ writeProjectFile,
117
+ ],
118
+ );
119
+
120
+ const handleLinkedElementsDelete = useCallback(
121
+ (selection: TimelineElement[]) => {
122
+ if (isLinkedSelectionOn()) return handleTimelineElementsDelete(withLinkPartners(selection));
123
+ const removed = new Set(selection.map(keyOf));
124
+ const orphans = clipsToUnlink(selection, usePlayerStore.getState().elements).filter(
125
+ (el) => !removed.has(keyOf(el)),
126
+ );
127
+ return handleTimelineElementsDelete(selection, orphans);
128
+ },
129
+ [handleTimelineElementsDelete],
130
+ );
131
+
132
+ const handleLinkedElementDelete = useCallback(
133
+ (element: TimelineElement) => handleLinkedElementsDelete([element]),
134
+ [handleLinkedElementsDelete],
135
+ );
136
+
137
+ const handleDeleteElementOnly = useCallback(
138
+ (element: TimelineElement) => {
139
+ const orphans = clipsToUnlink([element], usePlayerStore.getState().elements).filter(
140
+ (el) => keyOf(el) !== keyOf(element),
141
+ );
142
+ return handleTimelineElementsDelete([element], orphans);
143
+ },
144
+ [handleTimelineElementsDelete],
145
+ );
146
+
147
+ return {
148
+ handleLinkEdit,
149
+ handleLinkedElementsDelete,
150
+ handleLinkedElementDelete,
151
+ handleDeleteElementOnly,
152
+ };
153
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  liveTime,
32
32
  formatTime,
33
33
  } from "./player";
34
+ export { usePreviewIframeStore } from "./player/store/previewIframeStore";
34
35
  export type {
35
36
  PlayerHandle,
36
37
  PlayerHandleElement,
@@ -175,6 +176,7 @@ export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
175
176
  export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
176
177
  export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
177
178
  export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
179
+ export { useAudioMetersVisible } from "./utils/audioMeterVisibility";
178
180
  export { useClipboard } from "./hooks/useClipboard";
179
181
  export type { UseClipboardOptions } from "./hooks/useClipboard";
180
182
 
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Share of the clip height the sound strip takes under a video's thumbnails. */
4
+ const AUDIBLE_VIDEO_WAVE_SHARE = 0.38;
5
+
6
+ /** A video that carries sound: thumbnails on top, its waveform strip along the bottom. */
7
+ export function AudibleVideoClipContent({
8
+ thumbnail,
9
+ waveform,
10
+ }: {
11
+ thumbnail: ReactNode;
12
+ waveform: ReactNode;
13
+ }) {
14
+ const waveHeight = `${AUDIBLE_VIDEO_WAVE_SHARE * 100}%`;
15
+ return (
16
+ <div className="relative h-full w-full" data-testid="audible-video-clip">
17
+ <div className="absolute inset-x-0 top-0" style={{ bottom: waveHeight }}>
18
+ {thumbnail}
19
+ </div>
20
+ <div
21
+ className="absolute inset-x-0 bottom-0 bg-black/30"
22
+ data-testid="audible-video-wave"
23
+ style={{ height: waveHeight }}
24
+ >
25
+ {waveform}
26
+ </div>
27
+ </div>
28
+ );
29
+ }
@@ -96,7 +96,7 @@ describe("AudioWaveform", () => {
96
96
  act(() => root.unmount());
97
97
  });
98
98
 
99
- it("greys the clip in place when muted and draws the parent tick when linked", () => {
99
+ it("greys the clip in place when muted", () => {
100
100
  const host = document.createElement("div");
101
101
  host.className = "timeline-clip is-audio";
102
102
  document.body.append(host);
@@ -112,15 +112,42 @@ describe("AudioWaveform", () => {
112
112
  sessionEpoch={1}
113
113
  priority="visible"
114
114
  muted
115
- linked
116
115
  />,
117
116
  );
118
117
  });
119
118
 
120
119
  expect(host.getAttribute("data-audio-muted")).toBe("true");
121
- expect(host.querySelector(".timeline-audio-link")).not.toBeNull();
122
120
 
123
121
  act(() => root.unmount());
124
122
  expect(host.hasAttribute("data-audio-muted")).toBe(false);
125
123
  });
124
+
125
+ it("fills a short sound strip when the label band is dropped", () => {
126
+ const heights = [16, 0].map((labelInset) => {
127
+ const host = document.createElement("div");
128
+ document.body.append(host);
129
+ const root = createRoot(host);
130
+ act(() => {
131
+ root.render(
132
+ <AudioWaveform
133
+ audioUrl="/media/talk.mp4"
134
+ label=""
135
+ labelColor="#fff"
136
+ projectId="project-a"
137
+ sessionEpoch={1}
138
+ priority="visible"
139
+ {...(labelInset === 16 ? {} : { labelInset })}
140
+ />,
141
+ );
142
+ });
143
+ const canvas = host.querySelector("canvas");
144
+ const box = { top: canvas?.style.top, height: canvas?.style.height };
145
+ act(() => root.unmount());
146
+ return box;
147
+ });
148
+ expect(heights).toEqual([
149
+ { top: "16px", height: "calc(100% - 16px)" },
150
+ { top: "0px", height: "calc(100% - 0px)" },
151
+ ]);
152
+ });
126
153
  });
@@ -18,8 +18,7 @@ export interface AudioWaveformProps {
18
18
  priority: ThumbnailPriority;
19
19
  /** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
20
20
  muted?: boolean;
21
- /** Same media file as a video clip. Draws the 1px parent tick. */
22
- linked?: boolean;
21
+ labelInset?: number;
23
22
  }
24
23
 
25
24
  const BAR_STEP = 3;
@@ -167,7 +166,7 @@ export const AudioWaveform = memo(function AudioWaveform({
167
166
  sessionEpoch,
168
167
  priority,
169
168
  muted = false,
170
- linked = false,
169
+ labelInset = 16,
171
170
  }: AudioWaveformProps) {
172
171
  const rootRef = useRef<HTMLDivElement | null>(null);
173
172
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -236,17 +235,17 @@ export const AudioWaveform = memo(function AudioWaveform({
236
235
 
237
236
  return (
238
237
  <div ref={rootRef} className="absolute inset-0">
239
- {linked ? <span className="timeline-audio-link" aria-hidden="true" /> : null}
240
238
  <div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
241
239
  <canvas
242
240
  ref={setCanvasRef}
243
241
  className="absolute inset-x-0 bottom-0 w-full"
244
- style={{ top: 16, height: "calc(100% - 16px)" }}
242
+ style={{ top: labelInset, height: `calc(100% - ${labelInset}px)` }}
245
243
  />
246
244
  {snapshot.status === "loading" && (
247
245
  <div
248
- className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
246
+ className="absolute inset-x-0 bottom-0 animate-pulse"
249
247
  style={{
248
+ top: labelInset,
250
249
  background: "var(--timeline-thumbnail-shimmer)",
251
250
  }}
252
251
  />
@@ -256,7 +255,7 @@ export const AudioWaveform = memo(function AudioWaveform({
256
255
  {snapshot.status === "error" && (
257
256
  <div
258
257
  className="absolute inset-x-0 flex items-center justify-center gap-1.5"
259
- style={{ top: 16, bottom: 0 }}
258
+ style={{ top: labelInset, bottom: 0 }}
260
259
  >
261
260
  <div
262
261
  className="absolute inset-x-0"
@@ -0,0 +1,82 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { usePreviewIframeStore } from "../store/previewIframeStore";
8
+ import { ClipBadges } from "./ClipBadges";
9
+
10
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
11
+ configurable: true,
12
+ value: true,
13
+ });
14
+
15
+ let root: Root | null = null;
16
+
17
+ afterEach(() => {
18
+ act(() => root?.unmount());
19
+ root = null;
20
+ usePreviewIframeStore.getState().setIframe(null);
21
+ document.body.innerHTML = "";
22
+ });
23
+
24
+ const talk: TimelineElement = {
25
+ id: "talk",
26
+ domId: "talk",
27
+ tag: "video",
28
+ start: 0,
29
+ duration: 6,
30
+ track: 0,
31
+ hasAudio: true,
32
+ };
33
+
34
+ function render(el: TimelineElement) {
35
+ const host = document.createElement("div");
36
+ document.body.append(host);
37
+ root = createRoot(host);
38
+ act(() => root?.render(<ClipBadges el={el} />));
39
+ }
40
+
41
+ const labels = () =>
42
+ Array.from(document.querySelectorAll("[data-badge]")).map(
43
+ (badge) => `${badge.getAttribute("data-badge")}:${badge.textContent}`,
44
+ );
45
+
46
+ function mountPreview(html: string): Document {
47
+ const iframe = document.createElement("iframe");
48
+ document.body.append(iframe);
49
+ const doc = iframe.contentDocument;
50
+ if (!doc) throw new Error("no iframe document");
51
+ doc.body.innerHTML = html;
52
+ usePreviewIframeStore.getState().setIframe(iframe);
53
+ return doc;
54
+ }
55
+
56
+ describe("ClipBadges", () => {
57
+ it("renders nothing for an untouched clip", () => {
58
+ render(talk);
59
+ expect(document.querySelector("[data-testid='clip-badges']")).toBeNull();
60
+ });
61
+
62
+ it("falls back to the store's volume without a preview", () => {
63
+ render({ ...talk, volume: 1.8 });
64
+ expect(labels()).toEqual(["volume:180%"]);
65
+ });
66
+
67
+ it("reads the live node and follows an agent's attribute edit", async () => {
68
+ const doc = mountPreview(
69
+ `<video id="talk" data-has-audio="true" data-link="talk-pair" style="clip-path: inset(10px)"></video>`,
70
+ );
71
+ render(talk);
72
+ expect(labels()).toEqual(["link:🔗", "crop:Crop"]);
73
+ const node = doc.getElementById("talk");
74
+ await act(async () => {
75
+ node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}');
76
+ node?.setAttribute("data-volume", "0.6");
77
+ await new Promise((resolve) => setTimeout(resolve, 0));
78
+ });
79
+ expect(labels()).toEqual(["link:🔗", "look:Warm daylight", "crop:Crop", "more:+1"]);
80
+ expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%");
81
+ });
82
+ });