@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
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
- import { act, createElement } from "react";
2
+ import { act, createElement, type ReactElement } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
@@ -10,6 +10,11 @@ import {
10
10
  openProjectHistory,
11
11
  type StudioApiAdapter,
12
12
  } from "@hyperframes/studio-server";
13
+ import { useDomEditNudge } from "../components/editor/useDomEditNudge";
14
+ import { DomEditProvider, useDomEditActionsContext } from "../contexts/DomEditContext";
15
+ import { __resetForTests as resetNudgeKeys } from "../utils/canvasNudgeGate";
16
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
17
+ import { makeSelection } from "./domSelectionTestHarness";
13
18
  import { useEditHistoryActions } from "./useEditHistoryActions";
14
19
  import { usePersistentEditHistory } from "./usePersistentEditHistory";
15
20
  import { usePreviewPersistence } from "./usePreviewPersistence";
@@ -19,6 +24,7 @@ const page = (left: string, top = "0px") =>
19
24
  const BEFORE = page("10px");
20
25
  const AFTER = page("50px");
21
26
  const OUTSIDE = page("50px", "99px");
27
+ const NUDGED = page("51px");
22
28
 
23
29
  const cleanup: Array<() => unknown> = [];
24
30
  let scratch = "";
@@ -78,14 +84,15 @@ async function studio() {
78
84
  showToast: () => {},
79
85
  syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
80
86
  showHistoryRestoreNow: persistence.showHistoryRestoreNow,
81
- waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
87
+ waitForPendingDomEditSaves: persistence.settlePendingEdits,
82
88
  });
83
89
  return null;
84
90
  }
85
91
  const root = createRoot(document.createElement("div"));
86
92
  await act(async () => root.render(createElement(Harness)));
87
93
  cleanup.push(() => act(() => root.unmount()));
88
- const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
94
+ const element = (id: string) => iframe.contentDocument!.getElementById(id)!;
95
+ const box = () => element("box").style.left;
89
96
  const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
90
97
  const show = (html: string) => {
91
98
  iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
@@ -105,8 +112,16 @@ async function studio() {
105
112
  );
106
113
  await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
107
114
  };
115
+ /** Mounts `ui` beside Studio, as the canvas is, for the rest of the test. */
116
+ const mount = (ui: ReactElement) => {
117
+ const canvas = createRoot(document.createElement("div"));
118
+ act(() => canvas.render(ui));
119
+ cleanup.push(() => act(() => canvas.unmount()));
120
+ };
108
121
  return {
109
122
  history: () => history,
123
+ element,
124
+ mount,
110
125
  persistence: () => persistence,
111
126
  actions: () => actions,
112
127
  box,
@@ -164,3 +179,148 @@ it("an undo after an outside write ends with the preview showing the file the se
164
179
  expect(s.other()).toBe("0px");
165
180
  expect(s.box()).toBe("50px");
166
181
  });
182
+
183
+ /** The canvas's arrow-key nudge on the box; `save` stands in for the burst's save once the keys stop. */
184
+ function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void> }) {
185
+ const selection = makeSelection("Box", target);
186
+ const ref = <T,>(current: T) => ({ current });
187
+ useDomEditNudge({
188
+ selection,
189
+ groupSelections: [],
190
+ allowCanvasMovement: true,
191
+ selectionRef: ref(selection),
192
+ overlayRectRef: ref({ left: 0, top: 0, width: 100, height: 40, editScaleX: 1, editScaleY: 1 }),
193
+ groupOverlayItemsRef: ref([]),
194
+ gestureRef: ref(null),
195
+ groupGestureRef: ref(null),
196
+ blockedMoveRef: ref(null),
197
+ onManualDragStartRef: ref(() => {}),
198
+ onBlockedMoveRef: ref(() => {}),
199
+ onPathOffsetCommitRef: ref(save),
200
+ onGroupPathOffsetCommitRef: ref(async () => {}),
201
+ });
202
+ return null;
203
+ }
204
+
205
+ it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
206
+ const s = await studio();
207
+ await s.edit();
208
+ resetNudgeKeys();
209
+ let finish!: () => void;
210
+ const save = vi.fn(async () => {
211
+ await new Promise<void>((resolve) => (finish = resolve));
212
+ writeFileSync(s.path, NUDGED);
213
+ await s.history().recordEdit({
214
+ label: "Move layer",
215
+ files: { "index.html": { before: AFTER, after: NUDGED } },
216
+ });
217
+ });
218
+ s.mount(createElement(Nudge, { target: s.element("box"), save }));
219
+ act(() => {
220
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
221
+ });
222
+
223
+ const nudge = () => s.element("box").style.getPropertyValue("translate");
224
+ expect(nudge()).not.toBe("");
225
+
226
+ const undone = s.actions().undo();
227
+ expect(s.box()).toBe("50px");
228
+ expect(nudge()).toBe("");
229
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
230
+ expect(nudge()).toBe("");
231
+ finish();
232
+ await act(() => undone);
233
+ expect(save).toHaveBeenCalledTimes(1);
234
+ expect(s.file()).toBe(AFTER);
235
+ expect(s.box()).toBe("50px");
236
+ expect(nudge()).toBe("");
237
+ });
238
+
239
+ it("an undo pressed while a nudge's save is queued shows the nudge undone in the key's own task", async () => {
240
+ const s = await studio();
241
+ await s.edit();
242
+ resetNudgeKeys();
243
+ let finish!: () => void;
244
+ const save = vi.fn(async () => {
245
+ await new Promise<void>((resolve) => (finish = resolve));
246
+ writeFileSync(s.path, NUDGED);
247
+ await s.history().recordEdit({
248
+ label: "Move layer",
249
+ files: { "index.html": { before: AFTER, after: NUDGED } },
250
+ });
251
+ });
252
+ s.mount(createElement(Nudge, { target: s.element("box"), save }));
253
+ act(() => {
254
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
255
+ });
256
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
257
+ const nudge = () => s.element("box").style.getPropertyValue("translate");
258
+ expect(nudge()).not.toBe("");
259
+
260
+ const undone = s.actions().undo();
261
+ expect(nudge()).toBe("");
262
+ expect(s.box()).toBe("50px");
263
+ finish();
264
+ await act(() => undone);
265
+ expect(s.file()).toBe(AFTER);
266
+ expect(nudge()).toBe("");
267
+ expect(s.box()).toBe("50px");
268
+ });
269
+
270
+ it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
271
+ const s = await studio();
272
+ await s.edit();
273
+ const box = s.element("box");
274
+ let fail!: () => void;
275
+ const handleDomStyleCommit = vi.fn(async () => {
276
+ box.style.left = "70px";
277
+ try {
278
+ await s.persistence().queueDomEditSave(async () => {
279
+ await new Promise<void>((resolve) => (fail = resolve));
280
+ throw new Error("The save failed.");
281
+ });
282
+ } catch (error) {
283
+ box.style.left = "50px";
284
+ throw error;
285
+ }
286
+ });
287
+ let actions!: ReturnType<typeof useDomEditActionsContext>;
288
+ function Canvas() {
289
+ actions = useDomEditActionsContext();
290
+ return null;
291
+ }
292
+ const value = { handleDomStyleCommit } as unknown as Parameters<
293
+ typeof DomEditProvider
294
+ >[0]["value"];
295
+ s.mount(
296
+ <DomEditProvider value={value}>
297
+ <Canvas />
298
+ </DomEditProvider>,
299
+ );
300
+ const failed = actions.handleDomStyleCommit("left", "70px");
301
+
302
+ const undone = s.actions().undo();
303
+ await vi.waitFor(() => expect(fail).toBeTypeOf("function"));
304
+ fail();
305
+ await expect(failed).rejects.toThrow("The save failed.");
306
+ await act(() => undone);
307
+ expect(s.file()).toBe(BEFORE);
308
+ expect(s.box()).toBe("10px");
309
+ });
310
+
311
+ it("an undo pressed while a tracked timeline edit fails still undoes the edit before it", async () => {
312
+ const s = await studio();
313
+ await s.edit();
314
+ let fail!: () => void;
315
+ const timelineEdit = trackedStudioEdit(
316
+ () => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
317
+ );
318
+ const failed = timelineEdit();
319
+
320
+ const undone = s.actions().undo();
321
+ fail();
322
+ await expect(failed).rejects.toThrow("The save failed.");
323
+ await act(() => undone);
324
+ expect(s.file()).toBe(BEFORE);
325
+ expect(s.box()).toBe("10px");
326
+ });
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react";
3
3
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
4
4
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5
5
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
6
+ import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";
6
7
 
7
8
  interface HistoryResult {
8
9
  ok: boolean;
@@ -74,7 +75,10 @@ export function useEditHistoryActions({
74
75
  const noun = direction === "undo" ? "Undo" : "Redo";
75
76
  // Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
76
77
  const predicted = editHistory.predict?.(direction) ?? null;
77
- const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
78
+ const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
79
+ const pendingEditShown =
80
+ !predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
81
+ const putBack = predictedShown ?? pendingEditShown;
78
82
  let result: HistoryResult = { ok: false, reason: "failed" };
79
83
  let serverSteppedShown = false;
80
84
  try {
@@ -83,8 +87,10 @@ export function useEditHistoryActions({
83
87
  readFile: readHistoryFile,
84
88
  serialize: serializeHistoryFiles,
85
89
  });
86
- serverSteppedShown =
87
- Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
90
+ const stepped = Boolean(result.ok && result.label);
91
+ serverSteppedShown = predictedShown
92
+ ? stepped && result.undoes === predicted?.id
93
+ : stepped && Boolean(pendingEditShown);
88
94
  } finally {
89
95
  if (putBack && !serverSteppedShown) putBack();
90
96
  }
@@ -100,7 +106,10 @@ export function useEditHistoryActions({
100
106
  return;
101
107
  }
102
108
  if (result.ok && result.label) {
103
- const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
109
+ const files =
110
+ serverSteppedShown && predictedShown
111
+ ? fromShown(result.files, predicted!.files)
112
+ : result.files;
104
113
  const restore = { paths: result.paths, files };
105
114
  onAfterUndoRedo?.(restore);
106
115
  if (activeCompPath && result.paths?.includes(activeCompPath)) {
@@ -19,6 +19,22 @@ afterEach(() => {
19
19
  document.body.innerHTML = "";
20
20
  });
21
21
 
22
+ // The runtime's element-picked message, posted by `from`.
23
+ const picked = (from: Window | null, selector: string) =>
24
+ act(() => {
25
+ window.dispatchEvent(
26
+ new MessageEvent("message", {
27
+ source: from,
28
+ data: {
29
+ source: "hf-preview",
30
+ type: "element-picked",
31
+ elementInfo: { selector, tagName: "h1" },
32
+ ...runtimeProtocolMetadata(30),
33
+ },
34
+ }),
35
+ );
36
+ });
37
+
22
38
  // The preview page: the saved markup plus what the runtime adds to it.
23
39
  function mountPreview(mounted = ""): HTMLIFrameElement {
24
40
  const iframe = document.createElement("iframe");
@@ -61,19 +77,7 @@ function mountPicker(
61
77
  }
62
78
  root = createRoot(document.createElement("div"));
63
79
  act(() => root?.render(React.createElement(Harness)));
64
- act(() => {
65
- window.dispatchEvent(
66
- new MessageEvent("message", {
67
- source: iframe.contentWindow,
68
- data: {
69
- source: "hf-preview",
70
- type: "element-picked",
71
- elementInfo: { selector, tagName: "h1" },
72
- ...runtimeProtocolMetadata(30),
73
- },
74
- }),
75
- );
76
- });
80
+ picked(iframe.contentWindow, selector);
77
81
  const picker = () => api as ReturnType<typeof useElementPicker>;
78
82
  return {
79
83
  picker,
@@ -170,3 +174,48 @@ describe("an edit to a picked element without an id", () => {
170
174
  expect(synced).toEqual([]);
171
175
  });
172
176
  });
177
+
178
+ describe("a pick across a reload that swaps the preview frame", () => {
179
+ function mountSwapping() {
180
+ const live = mountPreview();
181
+ const ref: { current: HTMLIFrameElement | null } = { current: live };
182
+ let api: ReturnType<typeof useElementPicker> | null = null;
183
+ function Harness() {
184
+ api = useElementPicker(ref);
185
+ return null;
186
+ }
187
+ root = createRoot(document.createElement("div"));
188
+ act(() => root?.render(React.createElement(Harness)));
189
+ return { live, ref, picker: () => api as ReturnType<typeof useElementPicker> };
190
+ }
191
+
192
+ it("keeps a pick posted by the frame it was made in after a new frame took its place", () => {
193
+ const { live, ref, picker } = mountSwapping();
194
+ picked(live.contentWindow, "h1");
195
+ act(() => picker().clearPick());
196
+ // The reload promotes its frame before the old frame's pick message is handled.
197
+ const old = live.contentWindow;
198
+ ref.current = mountPreview();
199
+ live.remove();
200
+ picked(old, "h1.next");
201
+ expect(picker().pickedElement?.selector).toBe("h1.next");
202
+ });
203
+
204
+ it("does not keep trusting a zoomed frame once the zoom is gone", () => {
205
+ const { picker } = mountSwapping();
206
+ const zoom = mountPreview();
207
+ act(() => picker().setActiveIframe(zoom));
208
+ picked(zoom.contentWindow, "h1");
209
+ expect(picker().pickedElement?.selector).toBe("h1");
210
+ act(() => picker().setActiveIframe(null));
211
+ picked(zoom.contentWindow, "h1.late");
212
+ expect(picker().pickedElement?.selector).toBe("h1");
213
+ });
214
+
215
+ it("still ignores a frame it never showed", () => {
216
+ const { picker } = mountSwapping();
217
+ const stranger = mountPreview();
218
+ picked(stranger.contentWindow, "h1");
219
+ expect(picker().pickedElement).toBeNull();
220
+ });
221
+ });
@@ -97,6 +97,8 @@ export function useElementPicker(
97
97
  setPickedElement(null);
98
98
  }, []);
99
99
 
100
+ const windowsThatWerePrimary = useRef(new WeakSet<MessageEventSource>());
101
+
100
102
  // Listen for picker messages from the iframe
101
103
  useMountEffect(() => {
102
104
  // One guard per message field, then one branch per message type.
@@ -105,10 +107,16 @@ export function useElementPicker(
105
107
  const data = e.data;
106
108
  if (data?.source !== "hf-preview") return;
107
109
  if (!acceptStudioRuntimeMessage(data)) return;
108
- // Accept events from either the primary iframe or the active override
110
+ // The primary frame, the active override, or a primary frame a reload just swapped out.
109
111
  const activeIframe = getActiveIframe();
110
112
  if (!activeIframe) return;
111
- if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow)
113
+ if (!e.source) return;
114
+ if (e.source === iframeRef.current?.contentWindow)
115
+ windowsThatWerePrimary.current.add(e.source);
116
+ else if (
117
+ e.source !== activeIframe.contentWindow &&
118
+ !windowsThatWerePrimary.current.has(e.source)
119
+ )
112
120
  return;
113
121
 
114
122
  if (data.type === "element-picked" && data.elementInfo) {
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../player";
3
+ import { requestFreezeFrame } from "./useFreezeFrame";
4
+
5
+ afterEach(() => vi.unstubAllGlobals());
6
+
7
+ const element: TimelineElement = {
8
+ id: "talk",
9
+ domId: "talk",
10
+ tag: "video",
11
+ start: 5,
12
+ duration: 6,
13
+ track: 0,
14
+ parentCompositionStart: 4,
15
+ };
16
+
17
+ function stubFetch(freezeResponse: { status: number; body: unknown }) {
18
+ const calls: Array<{ url: string; body: unknown }> = [];
19
+ vi.stubGlobal(
20
+ "fetch",
21
+ vi.fn(async (url: string, init?: { body?: string }) => {
22
+ calls.push({ url, body: init?.body ? JSON.parse(init.body) : null });
23
+ if (url.includes("/files/")) return new Response(JSON.stringify({ version: "v1" }));
24
+ return new Response(JSON.stringify(freezeResponse.body), { status: freezeResponse.status });
25
+ }),
26
+ );
27
+ return calls;
28
+ }
29
+
30
+ describe("requestFreezeFrame", () => {
31
+ it("posts the clip target, file version and an authored-time playhead", async () => {
32
+ const calls = stubFetch({ status: 200, body: { before: "a", after: "b", version: "v2" } });
33
+ const result = await requestFreezeFrame({
34
+ projectId: "p",
35
+ path: "scene.html",
36
+ element,
37
+ playhead: 7.5,
38
+ });
39
+ expect(result).toEqual({ before: "a", after: "b", version: "v2" });
40
+ const post = calls[1];
41
+ expect(post?.url).toContain("/file-mutations/freeze-frame");
42
+ expect(post?.body).toMatchObject({
43
+ path: "scene.html",
44
+ expectedVersion: "v1",
45
+ target: { id: "talk" },
46
+ playhead: 3.5,
47
+ });
48
+ });
49
+
50
+ it("surfaces the server's error", async () => {
51
+ stubFetch({ status: 400, body: { error: "Move the playhead inside a video clip to freeze" } });
52
+ await expect(
53
+ requestFreezeFrame({ projectId: "p", path: "index.html", element, playhead: 7 }),
54
+ ).rejects.toThrow("Move the playhead inside a video clip to freeze");
55
+ });
56
+ });
@@ -0,0 +1,121 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { toAuthoredStart } from "../player/store/timelineElement";
4
+ import { buildPatchTarget } from "../utils/timelineElementSplit";
5
+ import { buildProjectApiPath } from "../utils/projectRouting";
6
+ import { markStudioWriteToken } from "../utils/studioFileVersion";
7
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
8
+ import type { RecordEditInput } from "../utils/studioFileHistory";
9
+
10
+ type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
11
+
12
+ interface FreezeFrameResponse {
13
+ before: string;
14
+ after: string;
15
+ version: string;
16
+ }
17
+
18
+ function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
19
+ if (typeof value !== "object" || value === null) return false;
20
+ const body: Partial<Record<keyof FreezeFrameResponse, unknown>> = value;
21
+ return (
22
+ typeof body.before === "string" &&
23
+ typeof body.after === "string" &&
24
+ typeof body.version === "string"
25
+ );
26
+ }
27
+
28
+ function errorOf(value: unknown): string | null {
29
+ if (typeof value !== "object" || value === null || !("error" in value)) return null;
30
+ return typeof value.error === "string" ? value.error : null;
31
+ }
32
+
33
+ async function readVersion(projectId: string, path: string): Promise<string> {
34
+ const response = await fetch(
35
+ buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
36
+ );
37
+ const body: unknown = await response.json().catch(() => null);
38
+ const version =
39
+ typeof body === "object" && body !== null && "version" in body ? body.version : null;
40
+ if (!response.ok || typeof version !== "string") throw new Error(`Could not read ${path}`);
41
+ return version;
42
+ }
43
+
44
+ /** POST the freeze to studio-server, which extracts the still and writes the whole edit at once. */
45
+ export async function requestFreezeFrame(input: {
46
+ projectId: string;
47
+ path: string;
48
+ element: TimelineElement;
49
+ playhead: number;
50
+ }): Promise<FreezeFrameResponse> {
51
+ const target = buildPatchTarget(input.element);
52
+ if (!target) throw new Error("This clip has no id to freeze it by");
53
+ const expectedVersion = await readVersion(input.projectId, input.path);
54
+ const transactionToken = `freeze:${crypto.randomUUID()}`;
55
+ markStudioWriteToken(transactionToken);
56
+ const response = await fetch(
57
+ buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
58
+ {
59
+ method: "POST",
60
+ headers: {
61
+ "Content-Type": "application/json",
62
+ "X-Hyperframes-Write-Token": transactionToken,
63
+ },
64
+ body: JSON.stringify({
65
+ path: input.path,
66
+ expectedVersion,
67
+ target,
68
+ playhead: toAuthoredStart(input.element, input.playhead),
69
+ transactionToken,
70
+ }),
71
+ },
72
+ );
73
+ const body: unknown = await response.json().catch(() => null);
74
+ if (!response.ok || !isFreezeFrameResponse(body)) {
75
+ throw new Error(errorOf(body) ?? `Freeze frame failed (${response.status})`);
76
+ }
77
+ return body;
78
+ }
79
+
80
+ interface UseFreezeFrameOptions {
81
+ projectId: string | null;
82
+ activeCompPath: string | null;
83
+ showToast: (message: string, tone?: "error" | "info") => void;
84
+ writeProjectFile: ProjectFileWriter;
85
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
86
+ recordEdit: (input: RecordEditInput) => Promise<void>;
87
+ reloadPreview: () => void;
88
+ forceReloadSdkSession?: () => void;
89
+ isRecordingRef?: React.RefObject<boolean>;
90
+ }
91
+
92
+ export function useFreezeFrame(options: UseFreezeFrameOptions) {
93
+ const optionsRef = useRef(options);
94
+ optionsRef.current = options;
95
+
96
+ return useCallback(async (element: TimelineElement, playhead: number) => {
97
+ const opts = optionsRef.current;
98
+ if (opts.isRecordingRef?.current) {
99
+ opts.showToast("Cannot edit timeline while recording", "error");
100
+ return;
101
+ }
102
+ const projectId = opts.projectId;
103
+ if (!projectId) return;
104
+ const path = element.sourceFile || opts.activeCompPath || "index.html";
105
+ try {
106
+ await serializeStudioFileMutations(opts.writeProjectFile, [path], async () => {
107
+ const result = await requestFreezeFrame({ projectId, path, element, playhead });
108
+ await opts.recordEdit({
109
+ label: "Freeze frame",
110
+ files: { [path]: { before: result.before, after: result.after } },
111
+ });
112
+ opts.observeProjectFileVersion?.(path, result.version);
113
+ });
114
+ opts.forceReloadSdkSession?.();
115
+ opts.reloadPreview();
116
+ opts.showToast(`Froze a 2 s still at ${playhead.toFixed(2)}s`, "info");
117
+ } catch (error) {
118
+ opts.showToast(error instanceof Error ? error.message : "Freeze frame failed", "error");
119
+ }
120
+ }, []);
121
+ }
@@ -93,6 +93,7 @@ export interface UseGsapAwareEditingParams {
93
93
  next: { width: number; height: number },
94
94
  offset?: { x: number; y: number },
95
95
  restore?: () => void,
96
+ undoKey?: string,
96
97
  ) => Promise<void>;
97
98
  commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
98
99
  // GSAP script commit ops (from useGsapScriptCommits)
@@ -394,12 +395,11 @@ export function useGsapAwareEditing({
394
395
  makeFetchFallback(selection),
395
396
  );
396
397
  assertGsapEditPersisted(outcome);
397
- cropUndoKey = coalesceKey;
398
- // What the resize actually did, not what its animations suggest
399
- // it would do. An element whose scale is an instant hold has a
400
- // scale-group tween and still commits width/height, so guessing
401
- // from the tweens withheld an offset nobody had written and the
402
- // element snapped back to its authored position on every drag.
398
+ // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
399
+ if (outcome.status === "element-size")
400
+ await handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey);
401
+ else cropUndoKey = coalesceKey;
402
+ // What the resize did, not what its tweens suggest: a scale hold still commits a size.
403
403
  const ownsDragOffset =
404
404
  outcome.status === "persisted" && outcome.ownsDragOffset === true;
405
405
  logResize("intercept-handled", {
@@ -30,6 +30,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
30
30
  import type { MutationResult } from "./gsapScriptCommitTypes";
31
31
  import { persistSdkSerialize } from "../utils/sdkCutover";
32
32
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
33
+ import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
33
34
 
34
35
  // ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
35
36
 
@@ -426,6 +427,24 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
426
427
  );
427
428
  }
428
429
 
430
+ describe("a GSAP script commit", () => {
431
+ it("counts as a pending edit from its call until it lands, so a quick Cmd+Z waits for it", async () => {
432
+ mockFetchResult();
433
+ const deps = renderCommitHook();
434
+ let committed!: Promise<unknown>;
435
+ act(() => {
436
+ committed = deps.api.commitMutation(
437
+ selection,
438
+ { type: "remove-all-keyframes", animationId: "a" },
439
+ { label: "Remove all keyframes" },
440
+ );
441
+ });
442
+ expect(hasStudioPendingEdits()).toBe(true);
443
+ await act(async () => void (await committed));
444
+ expect(hasStudioPendingEdits()).toBe(false);
445
+ });
446
+ });
447
+
429
448
  describe("runCommit — instantPatch wiring", () => {
430
449
  it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
431
450
  mockFetchResult({ changed: false });
@@ -7,6 +7,7 @@ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"
7
7
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
8
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
9
  import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
10
+ import { trackedStudioEdit } from "../utils/studioPendingEdits";
10
11
  import {
11
12
  getStudioSaveErrorMessage,
12
13
  isStudioSaveErrorAlreadyToasted,
@@ -416,20 +417,20 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
416
417
  }
417
418
  return run();
418
419
  };
419
- const commit: CommitMutation = (selection, mutation, options) => {
420
+ const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
420
421
  if (!activeProjectId) return Promise.resolve();
421
422
  const file = selection.sourceFile || activeCompPath || "index.html";
422
423
  return serializeCommit(file, options.serializeKey, () =>
423
424
  runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
424
425
  );
425
- };
426
- commit.batch = (calls, options) => {
426
+ }) as CommitMutation;
427
+ commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
427
428
  if (!activeProjectId) return Promise.resolve();
428
429
  const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
429
430
  return serializeCommit(file, options.serializeKey, () =>
430
431
  runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
431
432
  );
432
- };
433
+ });
433
434
  return commit;
434
435
  }, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]);
435
436
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);