@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
@@ -206,7 +206,9 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
206
206
  const s = await studio();
207
207
  await s.edit();
208
208
  resetNudgeKeys();
209
+ let finish!: () => void;
209
210
  const save = vi.fn(async () => {
211
+ await new Promise<void>((resolve) => (finish = resolve));
210
212
  writeFileSync(s.path, NUDGED);
211
213
  await s.history().recordEdit({
212
214
  label: "Move layer",
@@ -218,12 +220,51 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
218
220
  window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
219
221
  });
220
222
 
223
+ const nudge = () => s.element("box").style.getPropertyValue("translate");
224
+ expect(nudge()).not.toBe("");
225
+
221
226
  const undone = s.actions().undo();
222
227
  expect(s.box()).toBe("50px");
228
+ expect(nudge()).toBe("");
229
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
230
+ expect(nudge()).toBe("");
231
+ finish();
223
232
  await act(() => undone);
224
233
  expect(save).toHaveBeenCalledTimes(1);
225
234
  expect(s.file()).toBe(AFTER);
226
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");
227
268
  });
228
269
 
229
270
  it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
@@ -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
+ }
@@ -10,13 +10,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
10
  const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
11
11
  const applySoftReload = vi.fn<(...args: unknown[]) => string>();
12
12
  const trackStudioEvent = vi.fn();
13
+ const readNestedFiles = vi.fn<(...args: unknown[]) => unknown>(() => null);
13
14
 
14
15
  vi.mock("./gsapRuntimePatch", () => ({
15
16
  patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
16
17
  }));
17
- vi.mock("../utils/gsapSoftReload", () => ({
18
+ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
19
+ ...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
18
20
  applySoftReload: (...args: unknown[]) => applySoftReload(...args),
19
21
  extractGsapScriptText: () => "",
22
+ readNestedFiles: (...args: unknown[]) => readNestedFiles(...args),
20
23
  }));
21
24
  vi.mock("../utils/studioTelemetry", () => ({
22
25
  trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
@@ -364,6 +367,7 @@ let cleanup: (() => void) | null = null;
364
367
  function renderCommitHook(
365
368
  options: {
366
369
  writeProjectFile?: (path: string, content: string) => Promise<void>;
370
+ iframe?: HTMLIFrameElement;
367
371
  } = {},
368
372
  ) {
369
373
  const reloadPreview = vi.fn();
@@ -378,7 +382,7 @@ function renderCommitHook(
378
382
  captured.api = useGsapScriptCommits({
379
383
  projectIdRef: { current: "proj-1" },
380
384
  activeCompPath: "index.html",
381
- previewIframeRef: { current: FAKE_IFRAME },
385
+ previewIframeRef: { current: options.iframe ?? FAKE_IFRAME },
382
386
  editHistory: { recordEdit },
383
387
  reloadPreview,
384
388
  onCacheInvalidate,
@@ -578,6 +582,7 @@ describe("runCommit — instantPatch wiring", () => {
578
582
  patchRuntimeTweenInPlace.mockReset();
579
583
  applySoftReload.mockReset();
580
584
  trackStudioEvent.mockReset();
585
+ readNestedFiles.mockReset().mockReturnValue(null);
581
586
  });
582
587
  afterEach(() => {
583
588
  cleanup?.();
@@ -585,6 +590,83 @@ describe("runCommit — instantPatch wiring", () => {
585
590
  vi.unstubAllGlobals();
586
591
  });
587
592
 
593
+ const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
594
+ const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
595
+
596
+ // A preview whose top-level timeline tweens an element written in a nested composition file.
597
+ function nestedPreview(): HTMLIFrameElement {
598
+ const doc = document.implementation.createHTMLDocument("");
599
+ doc.body.innerHTML = `<div data-composition-id="root"><div data-composition-file="compositions/sub.html"><div id="nwid"></div></div></div>`;
600
+ const nwid = doc.getElementById("nwid")!;
601
+ const tween = { targets: () => [nwid], vars: { width: 400 } };
602
+ return {
603
+ contentDocument: doc,
604
+ contentWindow: { __timelines: { root: { getChildren: () => [tween] } } },
605
+ } as unknown as HTMLIFrameElement;
606
+ }
607
+
608
+ // The mutation endpoint answers the commit; the files endpoint serves the nested file, or fails.
609
+ function mockServer(fileOk: boolean): ReturnType<typeof vi.fn> {
610
+ const body = {
611
+ ok: true,
612
+ changed: true,
613
+ before: "BEFORE",
614
+ after: "AFTER",
615
+ scriptText: NESTED_SCRIPT,
616
+ };
617
+ const fetch = vi.fn(async (url: string) =>
618
+ url.includes("/files/")
619
+ ? ({ ok: fileOk, json: async () => ({ content: SUB }) } as unknown as Response)
620
+ : ({ ok: true, json: async () => body } as unknown as Response),
621
+ );
622
+ vi.stubGlobal("fetch", fetch);
623
+ return fetch;
624
+ }
625
+
626
+ async function useRealNestedRead() {
627
+ const actual =
628
+ await vi.importActual<typeof import("../utils/gsapSoftReload")>("../utils/gsapSoftReload");
629
+ readNestedFiles.mockImplementation((...args) =>
630
+ actual.readNestedFiles(...(args as Parameters<typeof actual.readNestedFiles>)),
631
+ );
632
+ }
633
+
634
+ it("hands the soft reload the nested composition file it read for a reset element", async () => {
635
+ await useRealNestedRead();
636
+ applySoftReload.mockReturnValue("applied");
637
+ const fetch = mockServer(true);
638
+ const deps = renderCommitHook({ iframe: nestedPreview() });
639
+
640
+ await act(async () => {
641
+ await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
642
+ });
643
+
644
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
645
+ expect(applySoftReload).toHaveBeenCalledWith(
646
+ expect.anything(),
647
+ NESTED_SCRIPT,
648
+ expect.objectContaining({ nestedFiles: new Map([["compositions/sub.html", SUB]]) }),
649
+ );
650
+ });
651
+
652
+ it("reloads the preview in full when that nested file cannot be read", async () => {
653
+ await useRealNestedRead();
654
+ applySoftReload.mockReturnValue("cannot-soft-reload");
655
+ mockServer(false);
656
+ const deps = renderCommitHook({ iframe: nestedPreview() });
657
+
658
+ await act(async () => {
659
+ await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
660
+ });
661
+
662
+ expect(applySoftReload).toHaveBeenCalledWith(
663
+ expect.anything(),
664
+ NESTED_SCRIPT,
665
+ expect.objectContaining({ nestedFiles: null }),
666
+ );
667
+ expect(deps.reloadPreview).toHaveBeenCalledTimes(1);
668
+ });
669
+
588
670
  it("instantPatch succeeds: persists, invalidates cache, NO reload", async () => {
589
671
  patchRuntimeTweenInPlace.mockReturnValue(true);
590
672
  mockFetchResult();
@@ -3,7 +3,12 @@ import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mu
3
3
  import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory";
4
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import { usePlayerStore } from "../player/store/playerStore";
6
- import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
6
+ import {
7
+ applySoftReload,
8
+ extractGsapScriptText,
9
+ readNestedFiles,
10
+ settleNestedReads,
11
+ } from "../utils/gsapSoftReload";
7
12
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
8
13
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
14
  import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
@@ -142,9 +147,10 @@ function refreshMutationPreview(
142
147
  options: CommitMutationOptions,
143
148
  reloadPreview: () => void,
144
149
  onCacheInvalidate: () => void,
150
+ nestedFiles?: Map<string, string> | null,
145
151
  ): void {
146
152
  options.beforeReload?.();
147
- applyPreviewSync(iframe, result, options, reloadPreview);
153
+ applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
148
154
  onCacheInvalidate();
149
155
  }
150
156
 
@@ -168,6 +174,7 @@ function syncCommittedGsapMutation({
168
174
  forceReloadSdkSession,
169
175
  reloadPreview,
170
176
  onCacheInvalidate,
177
+ nestedFiles,
171
178
  }: {
172
179
  iframe: HTMLIFrameElement | null;
173
180
  selection: DomEditSelection;
@@ -179,6 +186,7 @@ function syncCommittedGsapMutation({
179
186
  forceReloadSdkSession?: () => void;
180
187
  reloadPreview: () => void;
181
188
  onCacheInvalidate: () => void;
189
+ nestedFiles?: Map<string, string> | null;
182
190
  }): void {
183
191
  if (result.after != null) onFileContentChanged?.(targetPath, result.after);
184
192
  // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
@@ -196,7 +204,7 @@ function syncCommittedGsapMutation({
196
204
  iframe?.contentDocument,
197
205
  );
198
206
  }
199
- refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
207
+ refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate, nestedFiles);
200
208
  }
201
209
 
202
210
  /**
@@ -219,6 +227,7 @@ function softReloadOrEscalate(
219
227
  reloadPreview: () => void,
220
228
  origin: "preview_sync" | "sdk_refresh",
221
229
  authoredHtml?: string,
230
+ nestedFiles?: Map<string, string> | null,
222
231
  ): void {
223
232
  // Seek the rebuilt timeline to the studio's own authoritative scrub position,
224
233
  // not the iframe's raw `__player.getTime()` — see the comment in
@@ -228,6 +237,7 @@ function softReloadOrEscalate(
228
237
  onAsyncFailure: reloadPreview,
229
238
  currentTimeOverride: currentTime,
230
239
  authoredHtml,
240
+ nestedFiles,
231
241
  });
232
242
  if (result === "applied") return;
233
243
  trackStudioEvent("gsap_soft_reload_outcome", {
@@ -252,6 +262,7 @@ export function applyPreviewSync(
252
262
  result: MutationResult,
253
263
  options: CommitMutationOptions,
254
264
  reloadPreview: () => void,
265
+ nestedFiles?: Map<string, string> | null,
255
266
  ): void {
256
267
  const patches = instantPatchesFor(options);
257
268
  let needsFallback = options.previewFallbackLatch?.pending === true;
@@ -297,6 +308,7 @@ export function applyPreviewSync(
297
308
  reloadPreview,
298
309
  "preview_sync",
299
310
  result.after ?? undefined,
311
+ nestedFiles,
300
312
  );
301
313
  } else {
302
314
  reloadPreview();
@@ -349,6 +361,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
349
361
  return;
350
362
  }
351
363
  await recordMutationEdit(targetPath, result, options);
364
+ const nestedFiles = await settleNestedReads(
365
+ result.scriptText
366
+ ? readNestedFiles(previewIframeRef.current, result.scriptText, (path) =>
367
+ readSharedProjectFileContent(projectId, path),
368
+ )
369
+ : null,
370
+ );
352
371
  // The durable mutation belongs to the project captured when it was queued.
353
372
  // A later project must never receive its file state or preview refresh.
354
373
  if (!isActiveCommitTarget(projectIdRef, activeCompPathRef, projectId, compositionPath)) return;
@@ -363,6 +382,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
363
382
  forceReloadSdkSession,
364
383
  reloadPreview,
365
384
  onCacheInvalidate,
385
+ nestedFiles,
366
386
  });
367
387
  }, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
368
388
 
@@ -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
  );