@hyperframes/studio 0.8.106 → 0.8.108

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 (184) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
  4. package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
  9. package/dist/chunk-AOWVEVEU.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/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +377 -12
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +13445 -11874
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +6 -3
  21. package/src/agentToolsExport.test.ts +8 -0
  22. package/src/captions/keyboard.test.ts +6 -0
  23. package/src/components/EditorShell.tsx +2 -0
  24. package/src/components/StudioRightPanels.tsx +2 -0
  25. package/src/components/TimelineToolbar.test.tsx +27 -0
  26. package/src/components/TimelineToolbar.tsx +2 -5
  27. package/src/components/editor/CropPresetBar.tsx +69 -0
  28. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  29. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  30. package/src/components/editor/DomEditOverlay.tsx +12 -18
  31. package/src/components/editor/PropertyPanel.tsx +1 -14
  32. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  33. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  34. package/src/components/editor/SnapToolbar.tsx +3 -4
  35. package/src/components/editor/carveOutput.ts +54 -0
  36. package/src/components/editor/cropPresetStore.ts +33 -0
  37. package/src/components/editor/cropPresets.test.ts +41 -0
  38. package/src/components/editor/cropPresets.ts +24 -0
  39. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  40. package/src/components/editor/domEditingLayers.test.ts +21 -0
  41. package/src/components/editor/domEditingLayers.ts +2 -0
  42. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  43. package/src/components/editor/fontAssets.test.ts +12 -22
  44. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  45. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  46. package/src/components/editor/manualEdits.test.ts +4 -3
  47. package/src/components/editor/manualEditsDom.ts +28 -5
  48. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  49. package/src/components/editor/manualOffsetDrag.ts +15 -1
  50. package/src/components/editor/mediaAudioEdits.test.ts +271 -0
  51. package/src/components/editor/mediaAudioEdits.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 +290 -87
  55. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  58. package/src/components/editor/propertyPanelTypes.ts +2 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  60. package/src/components/editor/useDomEditNudge.ts +46 -23
  61. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  62. package/src/components/editor/useFxCarve.ts +3 -61
  63. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  64. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  65. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  66. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  67. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  68. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  69. package/src/components/panels/SlideshowPanel.tsx +12 -7
  70. package/src/contexts/DomEditContext.test.tsx +62 -0
  71. package/src/contexts/DomEditContext.tsx +94 -77
  72. package/src/contexts/TimelineEditContext.tsx +1 -0
  73. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  74. package/src/hooks/domEditCommitTypes.ts +10 -0
  75. package/src/hooks/domEditPersistFailure.ts +12 -1
  76. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  77. package/src/hooks/domStyleCommit.ts +2 -8
  78. package/src/hooks/gsapDragCommit.ts +3 -8
  79. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  80. package/src/hooks/gsapEditOutcome.ts +2 -1
  81. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  82. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  83. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  84. package/src/hooks/gsapResizeIntercept.ts +6 -2
  85. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  86. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  87. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  88. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  89. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  90. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  91. package/src/hooks/useAppHotkeys.test.ts +17 -0
  92. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  93. package/src/hooks/useAppHotkeys.ts +3 -3
  94. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  95. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  96. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  97. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  98. package/src/hooks/useDomEditCommits.ts +2 -0
  99. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  100. package/src/hooks/useDomEditPersist.ts +28 -13
  101. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  102. package/src/hooks/useDomEditSession.ts +2 -3
  103. package/src/hooks/useDomEditTextCommits.ts +4 -8
  104. package/src/hooks/useDomGeometryCommits.ts +11 -2
  105. package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
  106. package/src/hooks/useEditHistoryActions.ts +13 -4
  107. package/src/hooks/useElementPicker.test.ts +62 -13
  108. package/src/hooks/useElementPicker.ts +10 -2
  109. package/src/hooks/useFreezeFrame.test.ts +56 -0
  110. package/src/hooks/useFreezeFrame.ts +121 -0
  111. package/src/hooks/useGsapAwareEditing.ts +6 -6
  112. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  113. package/src/hooks/useGsapScriptCommits.ts +5 -4
  114. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  115. package/src/hooks/useInlineTextEdit.ts +9 -7
  116. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  117. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  118. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  119. package/src/hooks/usePersistentEditHistory.ts +1 -1
  120. package/src/hooks/usePreviewPersistence.ts +6 -0
  121. package/src/hooks/useRazorSplit.ts +14 -1
  122. package/src/hooks/useRenderClipContent.test.ts +102 -0
  123. package/src/hooks/useRenderClipContent.ts +30 -23
  124. package/src/hooks/useTimelineEditing.ts +2 -2
  125. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  126. package/src/index.ts +8 -0
  127. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  128. package/src/player/components/ClipBadges.test.tsx +82 -0
  129. package/src/player/components/ClipBadges.tsx +67 -0
  130. package/src/player/components/ClipContextMenu.tsx +22 -0
  131. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  132. package/src/player/components/ClipPeakMarks.tsx +71 -0
  133. package/src/player/components/Timeline.test.ts +0 -21
  134. package/src/player/components/Timeline.tsx +0 -1
  135. package/src/player/components/TimelineClip.tsx +2 -0
  136. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  137. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  138. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  139. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  140. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  141. package/src/player/components/clipMenuDuck.test.ts +117 -0
  142. package/src/player/components/clipMenuDuck.ts +142 -0
  143. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  144. package/src/player/components/clipMenuNormalize.ts +96 -0
  145. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  146. package/src/player/components/clipMenuToolItems.tsx +235 -0
  147. package/src/player/components/clipPeakRuns.test.ts +38 -0
  148. package/src/player/components/clipPeakRuns.ts +59 -0
  149. package/src/player/components/clipToolAttrs.test.ts +161 -0
  150. package/src/player/components/clipToolAttrs.ts +187 -0
  151. package/src/player/components/timelineCallbacks.ts +1 -0
  152. package/src/player/components/timelineLayout.ts +0 -28
  153. package/src/player/components/useClipToolState.ts +92 -0
  154. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  155. package/src/player/lib/playbackShortcuts.ts +3 -7
  156. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  157. package/src/player/lib/timelineElementHelpers.ts +25 -2
  158. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  159. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  160. package/src/timelineChromeExports.test.tsx +7 -0
  161. package/src/utils/audioMeterMath.test.ts +37 -0
  162. package/src/utils/audioMeterMath.ts +9 -3
  163. package/src/utils/exportPeakCeiling.ts +1 -0
  164. package/src/utils/gsapSoftReload.ts +3 -2
  165. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  166. package/src/utils/gsapUndoRestore.test.ts +145 -1
  167. package/src/utils/gsapUndoRestore.ts +70 -21
  168. package/src/utils/previewRelativePath.ts +19 -0
  169. package/src/utils/sourcePatcher.ts +2 -2
  170. package/src/utils/studioFontHelpers.ts +2 -23
  171. package/src/utils/studioHelpers.test.ts +30 -0
  172. package/src/utils/studioHelpers.ts +18 -4
  173. package/src/utils/studioPendingEdits.test.ts +129 -0
  174. package/src/utils/studioPendingEdits.ts +61 -18
  175. package/src/utils/timelineInspector.test.ts +33 -2
  176. package/src/utils/timelineInspector.ts +16 -4
  177. package/src/utils/typingTarget.test.ts +13 -0
  178. package/src/utils/typingTarget.ts +5 -2
  179. package/src/webmcp/useStudioAgentTools.ts +3 -1
  180. package/dist/chunk-46XP7J3B.js.map +0 -1
  181. package/dist/chunk-DI4LCJHL.js +0 -102
  182. package/dist/chunk-DI4LCJHL.js.map +0 -1
  183. package/dist/chunk-ICTZJPQQ.js.map +0 -1
  184. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
7
7
  import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
8
+ import { applyUndoRestoreToPreview } from "../utils/gsapUndoRestore";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -643,3 +644,29 @@ describe("useInlineTextEdit with styled runs", () => {
643
644
  act(() => root.unmount());
644
645
  });
645
646
  });
647
+
648
+ describe("an undo that lands while a text edit is open", () => {
649
+ it("marks the edit as drawing nothing, so the undo reverts the move and keeps the edit", () => {
650
+ const wrap = (body: string) => `<html><body>${body}</body></html>`;
651
+ const moved = `<h1 id="t" style="translate: 90px 60px">Title</h1>`;
652
+ document.body.innerHTML = moved;
653
+ const element = document.getElementById("t")!;
654
+ const { controls, root } = mount();
655
+ act(() => {
656
+ controls().start(element);
657
+ });
658
+ const contentWindow = { __player: { seek: vi.fn() }, __hfStudioManualEditsApply: vi.fn() };
659
+ const iframe = { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement;
660
+ const files = {
661
+ "index.html": { previous: wrap(moved), restored: wrap(`<h1 id="t">Title</h1>`) },
662
+ };
663
+
664
+ expect(element.getAttribute("data-hf-studio-manual-edit-gesture")).toMatch(/:edit$/);
665
+ expect(applyUndoRestoreToPreview(iframe, "index.html", files, 0, vi.fn())).toBe("soft");
666
+
667
+ expect(element.style.getPropertyValue("translate")).toBe("");
668
+ expect(element.getAttribute("contenteditable")).toBe("true");
669
+ expect(element.style.getPropertyValue("outline")).not.toBe("");
670
+ act(() => root.unmount());
671
+ });
672
+ });
@@ -5,7 +5,7 @@ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
5
5
  import {
6
6
  beginStudioManualEditGesture,
7
7
  endStudioManualEditGesture,
8
- noteStudioManualEditSave,
8
+ countStudioManualEditSave,
9
9
  } from "../components/editor/manualEditsDom";
10
10
 
11
11
  /**
@@ -141,7 +141,7 @@ export function useInlineTextEdit({
141
141
  original: element.innerHTML,
142
142
  outline: element.style.outline,
143
143
  outlineOffset: element.style.outlineOffset,
144
- gesture: beginStudioManualEditGesture(element),
144
+ gesture: beginStudioManualEditGesture(element, "edit"),
145
145
  };
146
146
  // Drawn on the element itself, not in Studio's overlay above it. This is
147
147
  // the only mark that says the caret is in the TEXT rather than the
@@ -181,11 +181,13 @@ export function useInlineTextEdit({
181
181
  // Counted and pending before the edit closes, so a reload waits for the save, not the mark.
182
182
  let save: (saving: unknown) => void = () => {};
183
183
  trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
184
- noteStudioManualEditSave(open.element);
185
- teardown();
186
- // After teardown, so the commit path's own resync does not fight an
187
- // element that is still editable.
188
- save(onCommit({ element: open.element, html, previousHtml: open.original }));
184
+ const commitText = () => {
185
+ teardown();
186
+ // After teardown, so the commit path's own resync does not fight an
187
+ // element that is still editable.
188
+ return onCommit({ element: open.element, html, previousHtml: open.original });
189
+ };
190
+ save(countStudioManualEditSave(open.element, commitText));
189
191
  }, [onCommit, teardown]);
190
192
 
191
193
  const cancel = useCallback(() => {
@@ -86,6 +86,21 @@ describe("keyframe shortcuts", () => {
86
86
  window.removeEventListener("keydown", playback);
87
87
  });
88
88
 
89
+ describe("with a focused picker", () => {
90
+ const picker = document.createElement("button");
91
+ picker.setAttribute("role", "combobox");
92
+ afterEach(() => picker.remove());
93
+
94
+ it("leaves K to a focused picker that owns typing, like a font combobox", () => {
95
+ const onAddKeyframe = mountKeyframeKeyboard(true);
96
+ document.body.append(picker);
97
+ picker.focus();
98
+
99
+ expect(press("k").defaultPrevented).toBe(false);
100
+ expect(onAddKeyframe).not.toHaveBeenCalled();
101
+ });
102
+ });
103
+
89
104
  it("K stays playback's stop key when no keyframeable element is selected", () => {
90
105
  const playback = vi.fn();
91
106
  window.addEventListener("keydown", playback);
@@ -1,23 +1,17 @@
1
1
  import { useEffect, useCallback } from "react";
2
2
  import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
3
+ import { isTypingTarget } from "../utils/typingTarget";
3
4
 
4
5
  interface KeyframeKeyboardOptions {
5
6
  enabled: boolean;
6
7
  onAddKeyframe?: () => void;
7
8
  }
8
9
 
9
- function isTextInput(el: Element | null): boolean {
10
- if (!el) return false;
11
- const tag = el.tagName;
12
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
13
- return (el as HTMLElement).isContentEditable === true;
14
- }
15
-
16
10
  export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
17
11
  const handler = useCallback(
18
12
  (e: KeyboardEvent) => {
19
13
  if (!enabled || !onAddKeyframe) return;
20
- if (isTextInput(document.activeElement)) return;
14
+ if (isTypingTarget(document.activeElement)) return;
21
15
  if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
22
16
  if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
23
17
  // K is also playback's stop key: claiming it here keeps playback from also pausing.
@@ -243,8 +243,14 @@ it("a step that cannot reach the server says so", async () => {
243
243
  });
244
244
  });
245
245
 
246
- it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
246
+ it("predicts a step from what this tab wrote as soon as its save ends, and not while a claim or step may have moved the history", async () => {
247
247
  const { hook, save, readFile } = await studio();
248
+ const real = globalThis.fetch;
249
+ const slowView = (url: string, init?: RequestInit) =>
250
+ url.endsWith("/history")
251
+ ? new Promise((r) => setTimeout(r, 50)).then(() => real(url, init))
252
+ : real(url, init);
253
+ vi.stubGlobal("fetch", slowView);
248
254
  save("B");
249
255
  const claim = hook().recordEdit({
250
256
  label: "Moved Title",
@@ -252,11 +258,8 @@ it("predicts a step from what this tab wrote, and not while a claim or step may
252
258
  });
253
259
  expect(hook().predict("undo")).toBeNull();
254
260
  await act(() => claim);
255
- const predicted = await vi.waitFor(() => {
256
- const next = hook().predict("undo");
257
- expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
258
- return next!;
259
- });
261
+ const predicted = hook().predict("undo")!;
262
+ expect(predicted?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
260
263
 
261
264
  hook().noteOutsideChange();
262
265
  expect(hook().predict("undo")).toBeNull();
@@ -223,7 +223,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
223
223
  const claimed = claimHeld(reply, label);
224
224
  if (claimed) own.remember(claimed, files);
225
225
  heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
226
- void refresh();
226
+ await refresh();
227
227
  },
228
228
  [projectId, refresh, own],
229
229
  );
@@ -159,6 +159,11 @@ export function usePreviewPersistence({
159
159
  if (result.status !== "clean") throw result.error;
160
160
  }, [drainPendingDomEditSaves]);
161
161
 
162
+ const settlePendingEdits = useCallback(async (): Promise<void> => {
163
+ await flushStudioPendingEdits();
164
+ await domEditSaveQueueRef.current?.waitForIdle();
165
+ }, []);
166
+
162
167
  const resetDomEditSaveQueueBreaker = useCallback(() => {
163
168
  domEditSaveQueueRef.current?.reset();
164
169
  setDomEditSaveQueuePaused(null);
@@ -253,6 +258,7 @@ export function usePreviewPersistence({
253
258
  queueDomEditSave,
254
259
  drainPendingDomEditSaves,
255
260
  waitForPendingDomEditSaves,
261
+ settlePendingEdits,
256
262
  domEditSaveQueuePaused,
257
263
  resetDomEditSaveQueueBreaker,
258
264
  applyCurrentStudioManualEditsToPreview,
@@ -6,6 +6,7 @@ import { trackStudioRazorSplit } from "../telemetry/events";
6
6
  import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
7
7
  import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
8
8
  import type { RecordEditInput } from "./timelineEditingHelpers";
9
+ import { useFreezeFrame } from "./useFreezeFrame";
9
10
 
10
11
  interface UseRazorSplitOptions {
11
12
  projectId: string | null;
@@ -135,5 +136,17 @@ export function useRazorSplit({
135
136
  [isRecordingRef, runCut, showToast],
136
137
  );
137
138
 
138
- return { handleRazorSplit, handleRazorSplitAll };
139
+ const handleFreezeFrame = useFreezeFrame({
140
+ projectId,
141
+ activeCompPath,
142
+ showToast,
143
+ writeProjectFile,
144
+ observeProjectFileVersion,
145
+ recordEdit,
146
+ reloadPreview,
147
+ forceReloadSdkSession,
148
+ isRecordingRef,
149
+ });
150
+
151
+ return { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame };
139
152
  }
@@ -5,6 +5,14 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { CompositionThumbnail, VideoThumbnail } from "../player";
7
7
  import { AudioWaveform } from "../player/components/AudioWaveform";
8
+ import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
9
+ import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
10
+
11
+ function unwrapPeakMarks(node: ReactNode): ReactNode {
12
+ return isValidElement<{ children?: ReactNode }>(node) && node.type === ClipPeakMarks
13
+ ? node.props.children
14
+ : node;
15
+ }
8
16
  import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
9
17
  import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
10
18
  import { buildCompositionThumbnailUrl } from "../player/components/CompositionThumbnail";
@@ -93,9 +101,51 @@ describe("useRenderClipContent", () => {
93
101
  root.render(React.createElement(Harness));
94
102
  });
95
103
  act(() => root.unmount());
104
+ return unwrapPeakMarks(content);
105
+ }
106
+
107
+ function renderRaw(el: TimelineElement): ReactNode {
108
+ const host = document.createElement("div");
109
+ document.body.append(host);
110
+ const root = createRoot(host);
111
+ let content: ReactNode = null;
112
+ function Harness() {
113
+ const render = useRenderClipContent({
114
+ projectIdRef: { current: "my-project" },
115
+ compIdToSrc: new Map(),
116
+ activePreviewUrl: null,
117
+ effectiveTimelineDuration: 12,
118
+ });
119
+ content = render(el, { clip: "#222", label: "#fff" });
120
+ return null;
121
+ }
122
+ act(() => root.render(React.createElement(Harness)));
123
+ act(() => root.unmount());
96
124
  return content;
97
125
  }
98
126
 
127
+ it("wraps a clip's waveform in peak marks at its own gain over its played source window", () => {
128
+ const content = renderRaw({
129
+ id: "voiceover",
130
+ tag: "audio",
131
+ start: 1,
132
+ duration: 4,
133
+ track: 1,
134
+ src: "assets/voiceover.mp3",
135
+ playbackStart: 2,
136
+ playbackRate: 1.5,
137
+ volume: 2,
138
+ });
139
+ expect(isValidElement(content) && content.type).toBe(ClipPeakMarks);
140
+ if (isValidElement<Record<string, unknown>>(content)) {
141
+ expect(content.props).toMatchObject({
142
+ peaksUrl: "/api/projects/my-project/peaks/assets/voiceover.mp3",
143
+ sourceWindow: { mediaStart: 2, sourceSpan: 6 },
144
+ gain: 2,
145
+ });
146
+ }
147
+ });
148
+
99
149
  it("renders audio clips as waveforms even when a composition preview URL is active", () => {
100
150
  const content = renderClipContent({
101
151
  id: "voiceover",
@@ -176,6 +226,58 @@ describe("useRenderClipContent", () => {
176
226
  }
177
227
  });
178
228
 
229
+ it("draws a waveform strip under the thumbnails of a video with sound", () => {
230
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
231
+ const content = renderClipContent(
232
+ {
233
+ id: "talk",
234
+ tag: "video",
235
+ start: 0,
236
+ duration: 4,
237
+ track: 0,
238
+ src: "talk.mp4",
239
+ hasAudio: true,
240
+ },
241
+ null,
242
+ );
243
+ expect(isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)).toBe(true);
244
+ if (!isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)) return;
245
+ expect(content.type).toBe(AudibleVideoClipContent);
246
+ const { thumbnail } = content.props;
247
+ const waveform = unwrapPeakMarks(content.props.waveform);
248
+ expect(isValidElement(thumbnail) && thumbnail.type).toBe(VideoThumbnail);
249
+ expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe(
250
+ "/api/projects/my-project/waveform/talk.mp4",
251
+ );
252
+ });
253
+
254
+ it("keeps a muted or silent video to thumbnails only", () => {
255
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
256
+ for (const extra of [{ hasAudio: true, muted: true }, { hasAudio: false }]) {
257
+ const content = renderClipContent(
258
+ { id: "b", tag: "video", start: 0, duration: 4, track: 0, src: "b.mp4", ...extra },
259
+ null,
260
+ );
261
+ expect(isValidElement(content) && content.type).toBe(VideoThumbnail);
262
+ }
263
+ });
264
+
265
+ it("still shows the sound strip when thumbnails are off", () => {
266
+ const content = renderClipContent(
267
+ {
268
+ id: "talk",
269
+ tag: "video",
270
+ start: 0,
271
+ duration: 4,
272
+ track: 0,
273
+ src: "talk.mp4",
274
+ hasAudio: true,
275
+ },
276
+ null,
277
+ );
278
+ expect(isValidElement(content) && content.type).toBe(AudibleVideoClipContent);
279
+ });
280
+
179
281
  it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
180
282
  usePlayerStore.setState({ thumbnailMode: "adaptive" });
181
283
 
@@ -1,4 +1,5 @@
1
1
  import { buildProjectApiPath } from "../utils/projectRouting";
2
+ import { resolvePreviewRelative } from "../utils/previewRelativePath";
2
3
  import { useCallback, type ReactNode } from "react";
3
4
  import { createElement } from "react";
4
5
  import { CompositionThumbnail, VideoThumbnail } from "../player";
@@ -11,6 +12,9 @@ import type { TimelineClipRenderContext } from "../player/components/TimelineTyp
11
12
  import { audioPillFlags } from "../player/components/audioClipLink";
12
13
  import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
13
14
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
15
+ import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
16
+ import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
17
+ import { clipHasSound } from "../player/components/clipMenuNormalize";
14
18
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
15
19
  import { usePlayerStore } from "../player/store/playerStore";
16
20
  import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
@@ -33,24 +37,6 @@ export function normalizeCompositionSrc(
33
37
  return compSrc;
34
38
  }
35
39
 
36
- /** Resolve a media src to its project-relative preview path, or null. */
37
- function resolvePreviewRelative(
38
- src: string | undefined,
39
- pid: string,
40
- origin: string,
41
- ): string | null {
42
- if (!src) return null;
43
- try {
44
- const parsed = new URL(src, origin);
45
- const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
46
- return parsed.pathname.startsWith(base)
47
- ? decodeURIComponent(parsed.pathname.slice(base.length))
48
- : null;
49
- } catch {
50
- return null;
51
- }
52
- }
53
-
54
40
  /**
55
41
  * The trimmed source slice as start/end fractions (0–1) of the source, so the
56
42
  * waveform can window its peaks to the clip edges. Undefined when the source
@@ -88,7 +74,7 @@ function renderAudioClip(
88
74
  ? buildProjectApiPath(pid, `/waveform/${encodedRelative}`)
89
75
  : undefined;
90
76
  const { start, end } = trimFractions(el);
91
- return createElement(AudioWaveform, {
77
+ const waveform = createElement(AudioWaveform, {
92
78
  audioUrl,
93
79
  waveformUrl,
94
80
  label: "",
@@ -100,6 +86,27 @@ function renderAudioClip(
100
86
  priority: context.priority,
101
87
  ...audioPillFlags(el, elements),
102
88
  });
89
+ return createElement(
90
+ ClipPeakMarks,
91
+ {
92
+ peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined,
93
+ sourceWindow: {
94
+ mediaStart: el.playbackStart ?? 0,
95
+ sourceSpan: el.duration * (el.playbackRate ?? 1),
96
+ },
97
+ gain: el.volume ?? 1,
98
+ },
99
+ waveform,
100
+ );
101
+ }
102
+
103
+ function withSoundStrip(
104
+ el: TimelineElement,
105
+ thumbnail: ReactNode,
106
+ waveform: () => ReactNode,
107
+ ): ReactNode {
108
+ if (el.tag !== "video" || !clipHasSound(el)) return thumbnail;
109
+ return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() });
103
110
  }
104
111
 
105
112
  export interface UseRenderClipContentOptions {
@@ -134,10 +141,9 @@ export function useRenderClipContent({
134
141
 
135
142
  // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
136
143
  // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
144
+ const waveform = () => renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
137
145
  if (effectiveMode === "hidden") {
138
- return rendersWaveform(el)
139
- ? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
140
- : null;
146
+ return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform);
141
147
  }
142
148
 
143
149
  let compSrc = el.compositionSrc;
@@ -222,7 +228,7 @@ export function useRenderClipContent({
222
228
  rich: context.rich,
223
229
  });
224
230
  }
225
- return createElement(VideoThumbnail, {
231
+ const thumbnail = createElement(VideoThumbnail, {
226
232
  videoSrc: mediaSrc,
227
233
  label: "",
228
234
  labelColor: style.label,
@@ -233,6 +239,7 @@ export function useRenderClipContent({
233
239
  sessionEpoch,
234
240
  priority: context.priority,
235
241
  });
242
+ return withSoundStrip(el, thumbnail, waveform);
236
243
  }
237
244
 
238
245
  if (htmlPreviewEligible) {
@@ -492,8 +492,7 @@ export function useTimelineEditing({
492
492
  });
493
493
 
494
494
  const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
495
-
496
- const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
495
+ const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
497
496
  projectId,
498
497
  activeCompPath,
499
498
  showToast,
@@ -575,6 +574,7 @@ export function useTimelineEditing({
575
574
  ),
576
575
  handleTimelineElementSplit: trackedRazorSplit,
577
576
  handleRazorSplit: trackedRazorSplit,
577
+ handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
578
578
  // Same selection the handler itself splits (useRazorSplit.ts).
579
579
  handleRazorSplitAll: track(
580
580
  guard(
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
- import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
2
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
3
3
 
4
4
  type AsyncFn = (...args: never[]) => Promise<unknown>;
5
5
 
@@ -11,11 +11,7 @@ export function useTrackPendingTimelineEdit() {
11
11
  const key = fn as unknown as AsyncFn;
12
12
  const cached = wrappedRef.current.get(key);
13
13
  if (cached) return cached as unknown as (...args: Args) => Promise<R>;
14
- const wrapped = (...args: Args): Promise<R> => {
15
- const result = fn(...args);
16
- trackStudioPendingEdit(result);
17
- return result;
18
- };
14
+ const wrapped = trackedStudioEdit(fn);
19
15
  wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
20
16
  return wrapped;
21
17
  }, []);
package/src/index.ts CHANGED
@@ -175,6 +175,7 @@ export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
175
175
  export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
176
176
  export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
177
177
  export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
178
+ export { useAudioMetersVisible } from "./utils/audioMeterVisibility";
178
179
  export { useClipboard } from "./hooks/useClipboard";
179
180
  export type { UseClipboardOptions } from "./hooks/useClipboard";
180
181
 
@@ -240,3 +241,10 @@ export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/m
240
241
  export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
241
242
  export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
242
243
  export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
244
+
245
+ export { buildStudioTools } from "./webmcp/useStudioAgentTools";
246
+ export type { StudioAgentToolsDeps } from "./webmcp/useStudioAgentTools";
247
+ export { collectStudioLookScene } from "./webmcp/tools/lookTools";
248
+ export type { StudioLookSnapshot } from "./webmcp/tools/lookTools";
249
+ export type { ModelContextTool } from "./webmcp/types";
250
+ export type { ToolResult } from "./webmcp/toolResult";
@@ -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
+ }
@@ -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
+ });